diff --git a/.librarian/state.yaml b/.librarian/state.yaml index fc72a1f57277..8416ffe13414 100644 --- a/.librarian/state.yaml +++ b/.librarian/state.yaml @@ -4304,3 +4304,12 @@ libraries: - README.rst - docs/summary_overview.md tag_format: '{id}-v{version}' + - id: google-auth + version: 2.43.0 + last_generated_commit: 102d9f92ac6ed649a61efd9b208e4d1de278e9bb + apis: [] + source_roots: + - . + preserve_regex: [] + remove_regex: [] + tag_format: v{version} diff --git a/packages/google-auth/.coveragerc b/packages/google-auth/.coveragerc new file mode 100644 index 000000000000..4192b8cf8d3c --- /dev/null +++ b/packages/google-auth/.coveragerc @@ -0,0 +1,17 @@ +[run] +branch = True + +[report] +omit = + */samples/* + */conftest.py + */google-cloud-sdk/lib/* + # NOTE: Temporarily disabling coverage for `_requests_base.py`. + */_requests_base.py +exclude_lines = + # Re-enable the standard pragma + pragma: NO COVER + # Ignore debug-only repr + def __repr__ + # Don't complain if tests don't hit defensive assertion code: + raise NotImplementedError diff --git a/packages/google-auth/.flake8 b/packages/google-auth/.flake8 new file mode 100644 index 000000000000..0574e0a3ab66 --- /dev/null +++ b/packages/google-auth/.flake8 @@ -0,0 +1,8 @@ +[flake8] +ignore = E203, E266, E501, W503 +exclude = + # Standard linting exemptions. + __pycache__, + .git, + *.pyc, + conf.py diff --git a/packages/google-auth/.repo-metadata.json b/packages/google-auth/.repo-metadata.json new file mode 100644 index 000000000000..0b76117d99d0 --- /dev/null +++ b/packages/google-auth/.repo-metadata.json @@ -0,0 +1,11 @@ +{ + "name": "google-auth", + "name_pretty": "Google Auth Python Library", + "client_documentation": "https://googleapis.dev/python/google-auth/latest", + "issue_tracker": "https://github.com/googleapis/google-cloud-python/issues", + "release_level": "stable", + "language": "python", + "library_type": "AUTH", + "repo": "googleapis/google-cloud-python", + "distribution_name": "google-auth" +} diff --git a/packages/google-auth/CHANGELOG.md b/packages/google-auth/CHANGELOG.md new file mode 100644 index 000000000000..a71cd68e3c88 --- /dev/null +++ b/packages/google-auth/CHANGELOG.md @@ -0,0 +1,1729 @@ +# Changelog + +[PyPI History][1] + +[1]: https://pypi.org/project/google-auth/#history + +## [2.43.0](https://github.com/googleapis/google-cloud-python/compare/google-auth-v2.42.1...google-auth-v2.43.0) (2025-11-05) + + +### Features + +* Add public wrapper for _mtls_helper.check_use_client_cert which enables mTLS if GOOGLE_API_USE_CLIENT_CERTIFICATE is not set, when the MWID/X.509 cert sources detected (#1859) Add public wrapper for check_use_client_cert which enables mTLS if +GOOGLE_API_USE_CLIENT_CERTIFICATE is not set, when the MWID/X.509 cert +sources detected. Also, fix check_use_client_cert to return boolean +value. +Change #1848 added the check_use_client_cert method that helps know if +client cert should be used for mTLS connection. However, that was in a +private class, thus, created a public wrapper of the same function so +that it can be used by python Client Libraries. Also, updated +check_use_client_cert to return a boolean value instead of existing +string value for better readability and future scope. +--------- ([1535eccbff0ad8f3fd6a9775316ac8b77dca66ba](https://github.com/googleapis/google-cloud-python/commit/1535eccbff0ad8f3fd6a9775316ac8b77dca66ba)) +* Enable mTLS if GOOGLE_API_USE_CLIENT_CERTIFICATE is not set, if the MWID/X.509 cert sources detected (#1848) The Python SDK will use a hybrid approach for mTLS enablement: +- If the GOOGLE_API_USE_CLIENT_CERTIFICATE environment variable is set +(either true or false), the SDK will respect that setting. This is +necessary for test scenarios and users who need to explicitly control +mTLS behavior. +- If the GOOGLE_API_USE_CLIENT_CERTIFICATE environment variable is not +set, the SDK will automatically enable mTLS only if it detects Managed +Workload Identity (MWID) or X.509 Workforce Identity Federation (WIF) +certificate sources. In other cases where the variable is not set, mTLS +will remain disabled. +** This change also adds the helper method `check_use_client_cert` and +it's unit test, which will be used for checking the criteria for setting +the mTLS to true +** This change is only for Auth-Library, other changes will be created +for Client-Library use-cases. +--------- ([395e405b64b56ddb82ee639958c2e8056ad2e82b](https://github.com/googleapis/google-cloud-python/commit/395e405b64b56ddb82ee639958c2e8056ad2e82b)) +* onboard `google-auth` to librarian (#1838) This PR onboards `google-auth` library to the Librarian system. +Wait for +https://github.com/googleapis/google-auth-library-python/pull/1819. ([c503eaa511357d7a76cc1e1f1d3a3be2dabd5bca](https://github.com/googleapis/google-cloud-python/commit/c503eaa511357d7a76cc1e1f1d3a3be2dabd5bca)) + +## [2.42.1](https://github.com/googleapis/google-auth-library-python/compare/v2.42.0...v2.42.1) (2025-10-30) + + +### Bug Fixes + +* Catch ValueError for json.loads() ([#1842](https://github.com/googleapis/google-auth-library-python/issues/1842)) ([b074cad](https://github.com/googleapis/google-auth-library-python/commit/b074cad460589633adfc6744c01726ae86f2aa2b)) + +## [2.42.0](https://github.com/googleapis/google-auth-library-python/compare/v2.41.1...v2.42.0) (2025-10-24) + + +### Features + +* Add trust boundary support for external accounts. ([#1809](https://github.com/googleapis/google-auth-library-python/issues/1809)) ([36ecb1d](https://github.com/googleapis/google-auth-library-python/commit/36ecb1d65883477d27faf9c2281fc289659b9903)) + + +### Bug Fixes + +* Read scopes from ADC json for impersoanted cred ([#1820](https://github.com/googleapis/google-auth-library-python/issues/1820)) ([62c0fc8](https://github.com/googleapis/google-auth-library-python/commit/62c0fc82a3625542381f85c698595446fc99ddae)) + +## [2.41.1](https://github.com/googleapis/google-auth-library-python/compare/v2.41.0...v2.41.1) (2025-09-30) + + +### Bug Fixes + +* Suppress deprecation warning for ADC ([#1815](https://github.com/googleapis/google-auth-library-python/issues/1815)) ([751ce3f](https://github.com/googleapis/google-auth-library-python/commit/751ce3f625eb24029e9f0c59c081bdd3e18eb583)) + +## [2.41.0](https://github.com/googleapis/google-auth-library-python/compare/v2.40.3...v2.41.0) (2025-09-29) + + +### Features + +* Add support for cachetools 6.0 ([#1773](https://github.com/googleapis/google-auth-library-python/issues/1773)) ([af18060](https://github.com/googleapis/google-auth-library-python/commit/af18060d521baf86c219d66a26631decb3b28e79)) +* Add trust boundary support for service accounts and impersonation. ([#1778](https://github.com/googleapis/google-auth-library-python/issues/1778)) ([99be2ce](https://github.com/googleapis/google-auth-library-python/commit/99be2ce19401296718f880c7a80cd2e841df78bf)) + + +### Bug Fixes + +* Deprecating [load_credentials_from_dict](https://googleapis.dev/python/google-auth/latest/reference/google.auth.html#google.auth.load_credentials_from_dict) ([58b66ec](https://github.com/googleapis/google-auth-library-python/commit/58b66ec8069bfe5304c7da512fe89a8e838ce1ca)) +* Deprecating [load_credentials_from_file](https://googleapis.dev/python/google-auth/latest/reference/google.auth.html#google.auth.load_credentials_from_file) ([58b66ec](https://github.com/googleapis/google-auth-library-python/commit/58b66ec8069bfe5304c7da512fe89a8e838ce1ca)) +* Fix type error in credentials.py for python 3.7 and 3.8 ([#1805](https://github.com/googleapis/google-auth-library-python/issues/1805)) ([c30a6a7](https://github.com/googleapis/google-auth-library-python/commit/c30a6a781d3e385598a0ac28a370a7f4800010cc)) + + +### Documentation + +* Update user guide to include x509 feature. ([#1802](https://github.com/googleapis/google-auth-library-python/issues/1802)) ([2d89ab4](https://github.com/googleapis/google-auth-library-python/commit/2d89ab4d85568564e1f462f5b463991ffd9b82b1)) + +## [2.40.3](https://github.com/googleapis/google-auth-library-python/compare/v2.40.2...v2.40.3) (2025-06-04) + + +### Bug Fixes + +* Auth fetch token from default endpoint ([#1779](https://github.com/googleapis/google-auth-library-python/issues/1779)) ([88891cc](https://github.com/googleapis/google-auth-library-python/commit/88891cc596640b0bb3a2891532e2d32f2c9f0ec3)) +* Remove unnecessary call to mds service ([#1769](https://github.com/googleapis/google-auth-library-python/issues/1769)) ([7c61c7d](https://github.com/googleapis/google-auth-library-python/commit/7c61c7d0a42ceec3eab693065745a74f524acab0)) +* Retry 504 errors ([#1767](https://github.com/googleapis/google-auth-library-python/issues/1767)) ([554f967](https://github.com/googleapis/google-auth-library-python/commit/554f967620da2b02e5d44ac7463dcc2407ace5dd)) + +## [2.40.2](https://github.com/googleapis/google-auth-library-python/compare/v2.40.1...v2.40.2) (2025-05-21) + + +### Bug Fixes + +* Remove sync response logs in AuthorizedSession ([97ed1c8](https://github.com/googleapis/google-auth-library-python/commit/97ed1c8ef1a797af26c5639b618aa26360e9d868)) +* Update test to consider new error message from cryptography ([#1765](https://github.com/googleapis/google-auth-library-python/issues/1765)) ([44e38b6](https://github.com/googleapis/google-auth-library-python/commit/44e38b60002f9dbd524b1fe82fa8d4295afc68bc)) + +## [2.40.1](https://github.com/googleapis/google-auth-library-python/compare/v2.40.0...v2.40.1) (2025-05-06) + + +### Bug Fixes + +* Disable logging response body for async logs ([#1756](https://github.com/googleapis/google-auth-library-python/issues/1756)) ([2f0ddfe](https://github.com/googleapis/google-auth-library-python/commit/2f0ddfeb9f6c726c68beebd7eefd32c86f7f0963)) + +## [2.40.0](https://github.com/googleapis/google-auth-library-python/compare/v2.39.0...v2.40.0) (2025-04-29) + + +### Features + +* Add request response logging to auth ([#1678](https://github.com/googleapis/google-auth-library-python/issues/1678)) ([77ad53e](https://github.com/googleapis/google-auth-library-python/commit/77ad53eb00c74b3badc486c8207a16dbc49f37e5)) + + +### Bug Fixes + +* Correct webauthn JSON parsing to be compliant with standard. ([#1658](https://github.com/googleapis/google-auth-library-python/issues/1658)) ([0c5ef36](https://github.com/googleapis/google-auth-library-python/commit/0c5ef364fb13ca9d7d17100166de87732d752de8)) + +## [2.39.0](https://github.com/googleapis/google-auth-library-python/compare/v2.38.0...v2.39.0) (2025-04-14) + + +### Features + +* Adds GA support for X.509 workload identity federation ([#1695](https://github.com/googleapis/google-auth-library-python/issues/1695)) ([7495960](https://github.com/googleapis/google-auth-library-python/commit/74959605400f9a1976bbdc52c029943b634eb553)) + + +### Bug Fixes + +* Add impersonated SA via local ADC support for fetch_id_token ([#1740](https://github.com/googleapis/google-auth-library-python/issues/1740)) ([f249764](https://github.com/googleapis/google-auth-library-python/commit/f24976452d741de6a49d9b7a85cdab47812f5312)) +* Add missing packaging dependency for feature requiring urllib3 ([#1732](https://github.com/googleapis/google-auth-library-python/issues/1732)) ([221f4a8](https://github.com/googleapis/google-auth-library-python/commit/221f4a82fa25c1ad453b85bc8b7f2fc304724879)) +* Add request timeout for MDS requests ([#1699](https://github.com/googleapis/google-auth-library-python/issues/1699)) ([9f7d3fa](https://github.com/googleapis/google-auth-library-python/commit/9f7d3fa92c0e656a1c970182833abe2d0d3ad3ee)) +* Explicitly declare support for Python 3.13 ([#1741](https://github.com/googleapis/google-auth-library-python/issues/1741)) ([6fd04d5](https://github.com/googleapis/google-auth-library-python/commit/6fd04d57df90866f24b554c489f8f2653467d70e)) + +## [2.38.0](https://github.com/googleapis/google-auth-library-python/compare/v2.37.0...v2.38.0) (2025-01-23) + + +### Features + +* Adding domain-wide delegation flow in impersonated credential ([#1624](https://github.com/googleapis/google-auth-library-python/issues/1624)) ([34ee3fe](https://github.com/googleapis/google-auth-library-python/commit/34ee3fef8cba6a1bbaa46fa16b43af0d89b60b0f)) + + +### Documentation + +* Add warnings regarding consuming externally sourced credentials ([d049370](https://github.com/googleapis/google-auth-library-python/commit/d049370d266b50db0e09d7b292dbf33052b27853)) + +## [2.37.0](https://github.com/googleapis/google-auth-library-python/compare/v2.36.1...v2.37.0) (2024-12-11) + + +### Features + +* Allow users to use jwk keys for verifying ID token ([#1641](https://github.com/googleapis/google-auth-library-python/issues/1641)) ([98c3ed9](https://github.com/googleapis/google-auth-library-python/commit/98c3ed94a25bd99e89f87f9500408e8e65d79723)) + +## [2.36.1](https://github.com/googleapis/google-auth-library-python/compare/v2.36.0...v2.36.1) (2024-11-08) + + +### Bug Fixes + +* Improve user guide for Impersonation and SA ([#1627](https://github.com/googleapis/google-auth-library-python/issues/1627)) ([656307d](https://github.com/googleapis/google-auth-library-python/commit/656307d40941d2b72bb41e15238ebabba5ab6f52)) + +## [2.36.0](https://github.com/googleapis/google-auth-library-python/compare/v2.35.0...v2.36.0) (2024-10-30) + + +### Features + +* IAM signblob retries ([#1600](https://github.com/googleapis/google-auth-library-python/issues/1600)) ([484c8db](https://github.com/googleapis/google-auth-library-python/commit/484c8db151690a4ae7b6b0ae38db0a8ede88df69)) +* Making iam endpoint universe-aware ([#1604](https://github.com/googleapis/google-auth-library-python/issues/1604)) ([16c728d](https://github.com/googleapis/google-auth-library-python/commit/16c728d30adb36e19aabe34f0ed5a95152d9a135)) +* Support External Account Authorized User as a Source Credential for impersonated credentials in ADC ([#1608](https://github.com/googleapis/google-auth-library-python/issues/1608)) ([875796c](https://github.com/googleapis/google-auth-library-python/commit/875796cbace847797898bc506eac188edbaa3192)) + + +### Bug Fixes + +* Adding default parameters to updated interfaces ([#1622](https://github.com/googleapis/google-auth-library-python/issues/1622)) ([8cf1cb1](https://github.com/googleapis/google-auth-library-python/commit/8cf1cb1663fccd03ea17a1bf055d862bddf61406)) +* Change universe_domain to universe-domain ([#1613](https://github.com/googleapis/google-auth-library-python/issues/1613)) ([168fcc6](https://github.com/googleapis/google-auth-library-python/commit/168fcc63593cb1da018b86070e20a576d5b93b7b)) +* Remove base class to avoid type conflict ([#1619](https://github.com/googleapis/google-auth-library-python/issues/1619)) ([9e2789a](https://github.com/googleapis/google-auth-library-python/commit/9e2789a208d58221292873b0095ae4e8a924a703)) +* Revert templates for iam endpoints ([#1614](https://github.com/googleapis/google-auth-library-python/issues/1614)) ([0a4363a](https://github.com/googleapis/google-auth-library-python/commit/0a4363a556455a29ececc1339385826e26459c65)) +* Update secret ([#1611](https://github.com/googleapis/google-auth-library-python/issues/1611)) ([f070de0](https://github.com/googleapis/google-auth-library-python/commit/f070de06492a5eed3fab54313272acb5f603193a)) +* Update secret ([#1617](https://github.com/googleapis/google-auth-library-python/issues/1617)) ([10f42a7](https://github.com/googleapis/google-auth-library-python/commit/10f42a70e6089f1eecd060893632dd0ef40423f7)) +* Update secret ([#1621](https://github.com/googleapis/google-auth-library-python/issues/1621)) ([6be19fb](https://github.com/googleapis/google-auth-library-python/commit/6be19fb6ad571f3a8275db8ccdd7a69cf97e1ceb)) + +## [2.35.0](https://github.com/googleapis/google-auth-library-python/compare/v2.34.0...v2.35.0) (2024-09-17) + + +### Features + +* Add cred info to ADC creds ([#1587](https://github.com/googleapis/google-auth-library-python/issues/1587)) ([6f75dd5](https://github.com/googleapis/google-auth-library-python/commit/6f75dd5de9ee1da4509306ff2e6420b3d88f9d00)) +* Add support for asynchronous `AuthorizedSession` api ([#1577](https://github.com/googleapis/google-auth-library-python/issues/1577)) ([2910b6b](https://github.com/googleapis/google-auth-library-python/commit/2910b6b56f8b82ad6b2e78befb7d0b3fbe96042d)) + + +### Bug Fixes + +* Remove token_info call from token refresh path ([#1595](https://github.com/googleapis/google-auth-library-python/issues/1595)) ([afb9e5a](https://github.com/googleapis/google-auth-library-python/commit/afb9e5ac889ec7599976cf3cf8516d17f6b59633)) + +## [2.34.0](https://github.com/googleapis/google-auth-library-python/compare/v2.33.0...v2.34.0) (2024-08-13) + + +### Features + +* **auth:** Update get_client_ssl_credentials to support X.509 workload certs ([#1558](https://github.com/googleapis/google-auth-library-python/issues/1558)) ([18c2ec1](https://github.com/googleapis/google-auth-library-python/commit/18c2ec1b571d506c0dbcffc483aa5e7b95e1b246)) + + +### Bug Fixes + +* Retry token request on retryable status code ([#1563](https://github.com/googleapis/google-auth-library-python/issues/1563)) ([f858a15](https://github.com/googleapis/google-auth-library-python/commit/f858a151cb7e29d34578e03c9e3fd4110c6bc258)) + +## [2.33.0](https://github.com/googleapis/google-auth-library-python/compare/v2.32.0...v2.33.0) (2024-08-06) + + +### Features + +* Implement async `StaticCredentials` using access tokens ([#1559](https://github.com/googleapis/google-auth-library-python/issues/1559)) ([dc17dfc](https://github.com/googleapis/google-auth-library-python/commit/dc17dfc3fb65c87f2912300f0d11f79781240e78)) +* Implement base classes for credentials and request sessions ([#1551](https://github.com/googleapis/google-auth-library-python/issues/1551)) ([036dac4](https://github.com/googleapis/google-auth-library-python/commit/036dac43018b8cc26b5608e1bb21d6e3ee62a282)) + + +### Bug Fixes + +* **metadata:** Enhance retry logic for metadata server access in _metadata.py ([#1545](https://github.com/googleapis/google-auth-library-python/issues/1545)) ([61c2432](https://github.com/googleapis/google-auth-library-python/commit/61c24321e52f6e017eecee211e11260d621c909b)) + + +### Documentation + +* Update argument for Credentials initialization ([#1557](https://github.com/googleapis/google-auth-library-python/issues/1557)) ([40b9ed9](https://github.com/googleapis/google-auth-library-python/commit/40b9ed91a6b01948561cfc71edaaabdd7f362f17)) + +## [2.32.0](https://github.com/googleapis/google-auth-library-python/compare/v2.31.0...v2.32.0) (2024-07-08) + + +### Features + +* Adds support for X509 workload credential type ([#1541](https://github.com/googleapis/google-auth-library-python/issues/1541)) ([1270217](https://github.com/googleapis/google-auth-library-python/commit/1270217556a0249b6be8824e2b6209371f24e400)) + +## [2.31.0](https://github.com/googleapis/google-auth-library-python/compare/v2.30.0...v2.31.0) (2024-06-27) + + +### Features + +* Adds X509 workload cert logic ([#1527](https://github.com/googleapis/google-auth-library-python/issues/1527)) ([05220e0](https://github.com/googleapis/google-auth-library-python/commit/05220e06a23b3d4c398628918dab113abcce52db)) + + +### Bug Fixes + +* Added py.typed to MANIFEST.in ([#1526](https://github.com/googleapis/google-auth-library-python/issues/1526)) ([1829a3b](https://github.com/googleapis/google-auth-library-python/commit/1829a3be18aea5eca8b7272111d75eb61a6077b1)) +* Pass trust_env kwarg to ClientSession ([#1533](https://github.com/googleapis/google-auth-library-python/issues/1533)) ([6c15c9a](https://github.com/googleapis/google-auth-library-python/commit/6c15c9ad0ff90cf9bac044b38e72956aa3039cf6)), closes [#1530](https://github.com/googleapis/google-auth-library-python/issues/1530) + +## [2.30.0](https://github.com/googleapis/google-auth-library-python/compare/v2.29.0...v2.30.0) (2024-06-06) + + +### Features + +* Add WebAuthn plugin component to handle WebAuthn get assertion request ([#1464](https://github.com/googleapis/google-auth-library-python/issues/1464)) ([e25f336](https://github.com/googleapis/google-auth-library-python/commit/e25f336ab49c2018a222458a95ebe083e8a4eb2a)) +* ECP Provider drop cryptography requirement ([#1524](https://github.com/googleapis/google-auth-library-python/issues/1524)) ([a821d71](https://github.com/googleapis/google-auth-library-python/commit/a821d719e2fc7bcdc21737fdf175d6f06aa9a56a)) +* Enable webauthn plugin for security keys ([#1528](https://github.com/googleapis/google-auth-library-python/issues/1528)) ([e2d5e63](https://github.com/googleapis/google-auth-library-python/commit/e2d5e635da2cb2caf8240fb9e07fc381442a9d0c)) + + +### Bug Fixes + +* Fix id_token iam endpoint for non-gdu service credentials ([#1506](https://github.com/googleapis/google-auth-library-python/issues/1506)) ([93d681e](https://github.com/googleapis/google-auth-library-python/commit/93d681e6cfb15eb4a3efada623be8ba73b302257)) +* Makes default token_url universe aware ([#1514](https://github.com/googleapis/google-auth-library-python/issues/1514)) ([045776e](https://github.com/googleapis/google-auth-library-python/commit/045776e5dfa3fb172ffaeb59bfe5c637778a5d34)) + +## [2.29.0](https://github.com/googleapis/google-auth-library-python/compare/v2.28.2...v2.29.0) (2024-03-18) + + +### Features + +* Adds support for custom suppliers in AWS and Identity Pool credentials ([#1496](https://github.com/googleapis/google-auth-library-python/issues/1496)) ([3af1768](https://github.com/googleapis/google-auth-library-python/commit/3af176879fc356f785b9f5b449ce4270f437db7e)) + + +### Bug Fixes + +* Refactor tech debt in aws and identity pool credentials ([#1501](https://github.com/googleapis/google-auth-library-python/issues/1501)) ([ce435b0](https://github.com/googleapis/google-auth-library-python/commit/ce435b0b13f43ef9258c8693f6c3beac06da64e2)) + +## [2.28.2](https://github.com/googleapis/google-auth-library-python/compare/v2.28.1...v2.28.2) (2024-03-08) + + +### Bug Fixes + +* Remove gce log for expected 404 ([#1491](https://github.com/googleapis/google-auth-library-python/issues/1491)) ([cb04e49](https://github.com/googleapis/google-auth-library-python/commit/cb04e49efa17004bff3aaa9ed974396d10f839b5)) + +## [2.28.1](https://github.com/googleapis/google-auth-library-python/compare/v2.28.0...v2.28.1) (2024-02-21) + + +### Bug Fixes + +* Typo when setting the state for the pickle deserializer. ([#1479](https://github.com/googleapis/google-auth-library-python/issues/1479)) ([08b5cc3](https://github.com/googleapis/google-auth-library-python/commit/08b5cc38d09d388a5a7c11942b7992870ee012b7)) + +## [2.28.0](https://github.com/googleapis/google-auth-library-python/compare/v2.27.0...v2.28.0) (2024-02-15) + + +### Features + +* Adding universe domain support for downscroped credentials ([#1463](https://github.com/googleapis/google-auth-library-python/issues/1463)) ([fa8b7b2](https://github.com/googleapis/google-auth-library-python/commit/fa8b7b24ec32712aafff98c2d6c6a6cc5fd20ada)) + + +### Bug Fixes + +* Change log level to debug for return_none_for_not_found_error ([#1473](https://github.com/googleapis/google-auth-library-python/issues/1473)) ([a036b47](https://github.com/googleapis/google-auth-library-python/commit/a036b4797471227f9ce87d71868404c9ebcde7c7)) +* Make requests import conditional for gce universe domain ([#1476](https://github.com/googleapis/google-auth-library-python/issues/1476)) ([9bb64c8](https://github.com/googleapis/google-auth-library-python/commit/9bb64c81d2745541efcc3dfeec31808ec682557e)) + +## [2.27.0](https://github.com/googleapis/google-auth-library-python/compare/v2.26.2...v2.27.0) (2024-01-24) + + +### Features + +* Add optional account association for Authorized User credentials. ([#1458](https://github.com/googleapis/google-auth-library-python/issues/1458)) ([988153d](https://github.com/googleapis/google-auth-library-python/commit/988153dbc927de112969b952e3d0bb6edc27bb0a)) + + +### Bug Fixes + +* Allow custom universe domain for gce creds ([#1460](https://github.com/googleapis/google-auth-library-python/issues/1460)) ([7db5823](https://github.com/googleapis/google-auth-library-python/commit/7db5823b599150c597e67b0a350e04600ea6434e)) +* Conditionally import requests only if no request was passed by the caller. ([#1456](https://github.com/googleapis/google-auth-library-python/issues/1456)) ([9cd6742](https://github.com/googleapis/google-auth-library-python/commit/9cd67425e95faab15e57b258a70506b02bccb799)) + +## [2.26.2](https://github.com/googleapis/google-auth-library-python/compare/v2.26.1...v2.26.2) (2024-01-11) + + +### Bug Fixes + +* Read universe_domain for external account authorized user ([#1450](https://github.com/googleapis/google-auth-library-python/issues/1450)) ([1cc7df3](https://github.com/googleapis/google-auth-library-python/commit/1cc7df3505ad2083d85f3cb2476eeee250e7faf0)) + +## [2.26.1](https://github.com/googleapis/google-auth-library-python/compare/v2.26.0...v2.26.1) (2024-01-03) + + +### Bug Fixes + +* Ensure that refresh worker is pickle-able. ([#1447](https://github.com/googleapis/google-auth-library-python/issues/1447)) ([421c184](https://github.com/googleapis/google-auth-library-python/commit/421c184ff4654024afe3e64754318a6be0cc96fc)) + +## [2.26.0](https://github.com/googleapis/google-auth-library-python/compare/v2.25.2...v2.26.0) (2023-12-20) + + +### Features + +* Add optional non blocking refresh for sync auth code ([a6dc2c3](https://github.com/googleapis/google-auth-library-python/commit/a6dc2c336a5e76a478691e3bedd0418aae08b911)) +* Add optional non blocking refresh for sync auth code ([#1368](https://github.com/googleapis/google-auth-library-python/issues/1368)) ([a6dc2c3](https://github.com/googleapis/google-auth-library-python/commit/a6dc2c336a5e76a478691e3bedd0418aae08b911)) + + +### Bug Fixes + +* External account user cred universe domain support ([#1437](https://github.com/googleapis/google-auth-library-python/issues/1437)) ([75068f9](https://github.com/googleapis/google-auth-library-python/commit/75068f93453e6ea0b5c7be5561e7ba342c695e95)) +* Guard delete statements. Add default fallback for _use_non_blocking_refresh. ([#1445](https://github.com/googleapis/google-auth-library-python/issues/1445)) ([776d634](https://github.com/googleapis/google-auth-library-python/commit/776d634ac6d989b224f8dbfb11d166cb3025a342)) + +## [2.25.2](https://github.com/googleapis/google-auth-library-python/compare/v2.25.1...v2.25.2) (2023-12-08) + + +### Bug Fixes + +* Fix user cred universe domain issue ([#1436](https://github.com/googleapis/google-auth-library-python/issues/1436)) ([ae314a8](https://github.com/googleapis/google-auth-library-python/commit/ae314a8f8c63ada4e31683464c24e28df0f5af7f)) + +## [2.25.1](https://github.com/googleapis/google-auth-library-python/compare/v2.25.0...v2.25.1) (2023-12-06) + + +### Bug Fixes + +* Fix vm universe_domain bug ([#1433](https://github.com/googleapis/google-auth-library-python/issues/1433)) ([8683520](https://github.com/googleapis/google-auth-library-python/commit/8683520af48e1b501076ef16013b6a27cc173af0)) + +## [2.25.0](https://github.com/googleapis/google-auth-library-python/compare/v2.24.0...v2.25.0) (2023-12-04) + + +### Features + +* Add custom tls signer for ECP Provider. ([39eb287](https://github.com/googleapis/google-auth-library-python/commit/39eb2877a45f0a6264442a059f7ffe8acbd7c29e)) +* Add custom tls signer for ECP Provider. ([#1402](https://github.com/googleapis/google-auth-library-python/issues/1402)) ([39eb287](https://github.com/googleapis/google-auth-library-python/commit/39eb2877a45f0a6264442a059f7ffe8acbd7c29e)) + + +### Bug Fixes + +* Add with_universe_domain ([#1408](https://github.com/googleapis/google-auth-library-python/issues/1408)) ([505910c](https://github.com/googleapis/google-auth-library-python/commit/505910c6a0bb2472284a52cd69db212aa8555119)) +* Fixes issue where Python37DeprecationWarning cannot be filtered ([#1428](https://github.com/googleapis/google-auth-library-python/issues/1428)) ([f22f767](https://github.com/googleapis/google-auth-library-python/commit/f22f767a22a3b567c3784c2fb5c7f5ae11d3e10f)) +* Remove broken link in Python37DeprecationWarning ([#1430](https://github.com/googleapis/google-auth-library-python/issues/1430)) ([e2db602](https://github.com/googleapis/google-auth-library-python/commit/e2db602a4fe8aee7f2eb8b59e2eaf23f1ced1b24)) + +## [2.24.0](https://github.com/googleapis/google-auth-library-python/compare/v2.23.4...v2.24.0) (2023-11-29) + + +### Features + +* Add support for Python 3.12 ([#1421](https://github.com/googleapis/google-auth-library-python/issues/1421)) ([307916c](https://github.com/googleapis/google-auth-library-python/commit/307916cdd99fc99bb4de567848f0809746db0562)) +* Add universe domain support for VM cred ([#1409](https://github.com/googleapis/google-auth-library-python/issues/1409)) ([7ab0fce](https://github.com/googleapis/google-auth-library-python/commit/7ab0fced0008ad9283c955b7dd7e2b7db55ae5e7)) +* Modify the token refresh window ([#1419](https://github.com/googleapis/google-auth-library-python/issues/1419)) ([c6af1d6](https://github.com/googleapis/google-auth-library-python/commit/c6af1d692b43833baca978948376739547cf685a)) + + +### Bug Fixes + +* Add missing before request to async oauth2 credentials. ([#1420](https://github.com/googleapis/google-auth-library-python/issues/1420)) ([8eaa878](https://github.com/googleapis/google-auth-library-python/commit/8eaa878618f648b1dac35b9f420651f22e357445)) +* Auto create self signed jwt cred ([#1418](https://github.com/googleapis/google-auth-library-python/issues/1418)) ([6c610a5](https://github.com/googleapis/google-auth-library-python/commit/6c610a564e833545837991f473b98139740b8866)) +* Migrate datetime.utcnow for python 3.12 ([#1413](https://github.com/googleapis/google-auth-library-python/issues/1413)) ([e4d9c27](https://github.com/googleapis/google-auth-library-python/commit/e4d9c278375647e6c21754f42b81819827beb5e4)) + + +### Documentation + +* Update user cred doc ([#1414](https://github.com/googleapis/google-auth-library-python/issues/1414)) ([3f426bc](https://github.com/googleapis/google-auth-library-python/commit/3f426bc05c5620e612a54e21fb6562e965ba24bd)) + +## [2.23.4](https://github.com/googleapis/google-auth-library-python/compare/v2.23.3...v2.23.4) (2023-10-31) + + +### Bug Fixes + +* Export detect_gce_residency_linux function ([#1403](https://github.com/googleapis/google-auth-library-python/issues/1403)) ([809da13](https://github.com/googleapis/google-auth-library-python/commit/809da13cedcc2945b7e81f9c5b86b84f75e9113c)) + +## [2.23.3](https://github.com/googleapis/google-auth-library-python/compare/v2.23.2...v2.23.3) (2023-10-05) + + +### Bug Fixes + +* Serialize signer keys on __getstate__ for pickling ([#1394](https://github.com/googleapis/google-auth-library-python/issues/1394)) ([8b783b9](https://github.com/googleapis/google-auth-library-python/commit/8b783b904d6044433acc5ba0f0c166fc38b3ddb3)), closes [#1383](https://github.com/googleapis/google-auth-library-python/issues/1383) + +## [2.23.2](https://github.com/googleapis/google-auth-library-python/compare/v2.23.1...v2.23.2) (2023-09-28) + + +### Bug Fixes + +* Support urllib3<2.0 versions ([#1390](https://github.com/googleapis/google-auth-library-python/issues/1390)) ([07c464a](https://github.com/googleapis/google-auth-library-python/commit/07c464a75fd873f23ca78016e7754697f4511f59)) + +## [2.23.1](https://github.com/googleapis/google-auth-library-python/compare/v2.23.0...v2.23.1) (2023-09-26) + + +### Bug Fixes + +* Less restrictive content-type header check for google authentication (ignores charset) ([#1382](https://github.com/googleapis/google-auth-library-python/issues/1382)) ([7039beb](https://github.com/googleapis/google-auth-library-python/commit/7039beb63b8644be748cfc2fc79a2b8b643cda9f)) +* Trust boundary meta header renaming and using the schema from backend team. ([#1384](https://github.com/googleapis/google-auth-library-python/issues/1384)) ([2503d4a](https://github.com/googleapis/google-auth-library-python/commit/2503d4a50995d4f2756846a17b33997273ace5f1)) +* Update urllib3 to >= 2.0.5 ([#1389](https://github.com/googleapis/google-auth-library-python/issues/1389)) ([a99f3bb](https://github.com/googleapis/google-auth-library-python/commit/a99f3bbf97c07a87203b7471817cfb2a1662293d)) + +## [2.23.0](https://github.com/googleapis/google-auth-library-python/compare/v2.22.0...v2.23.0) (2023-09-11) + + +### Features + +* Add get_bq_config_path() to _cloud_sdk.py ([9f52f66](https://github.com/googleapis/google-auth-library-python/commit/9f52f665247ada59278ffddaaef3ada9e419154c)) +* Add get_bq_config_path() to _cloud_sdk.py ([#1358](https://github.com/googleapis/google-auth-library-python/issues/1358)) ([9f52f66](https://github.com/googleapis/google-auth-library-python/commit/9f52f665247ada59278ffddaaef3ada9e419154c)) + + +### Bug Fixes + +* Expose universe domain in credentials ([#1380](https://github.com/googleapis/google-auth-library-python/issues/1380)) ([8b8fce6](https://github.com/googleapis/google-auth-library-python/commit/8b8fce6a1e1ca6e0199cb5f15a90af477bf1c853)) +* Make external_account resistant to string type 'expires_in' responses from non-compliant services ([#1379](https://github.com/googleapis/google-auth-library-python/issues/1379)) ([01d3770](https://github.com/googleapis/google-auth-library-python/commit/01d37706d6750c20952cf01b6a616b23aafa5be9)) +* Missing ssj for impersonate cred ([#1377](https://github.com/googleapis/google-auth-library-python/issues/1377)) ([7d453dc](https://github.com/googleapis/google-auth-library-python/commit/7d453dc6408b908e24312a7bd3dc380ad43220be)) +* Skip checking projectid on cred if env var is set ([#1349](https://github.com/googleapis/google-auth-library-python/issues/1349)) ([a4135a3](https://github.com/googleapis/google-auth-library-python/commit/a4135a3e9620a2cbf99957858c13780b92ff707c)) + +## [2.22.0](https://github.com/googleapis/google-auth-library-python/compare/v2.21.0...v2.22.0) (2023-07-06) + + +### Features + +* Adding meta header for trust boundary ([#1334](https://github.com/googleapis/google-auth-library-python/issues/1334)) ([908c8d1](https://github.com/googleapis/google-auth-library-python/commit/908c8d1f3dd0053421a03a97d450cea7b87aa36d)) +* Introduce compatibility with native namespace packages ([#1205](https://github.com/googleapis/google-auth-library-python/issues/1205)) ([2f75922](https://github.com/googleapis/google-auth-library-python/commit/2f7592259ebcbcfe496dd3d1803d5a4e16aa0d20)) + + +### Bug Fixes + +* Deprecate UserAccessTokenCredentials ([#1344](https://github.com/googleapis/google-auth-library-python/issues/1344)) ([5f97fe9](https://github.com/googleapis/google-auth-library-python/commit/5f97fe9ddb164697d5b75aa539893f54721dcc92)) + +## [2.21.0](https://github.com/googleapis/google-auth-library-python/compare/v2.20.0...v2.21.0) (2023-06-26) + + +### Features + +* Add framework for BYOID metrics headers ([#1332](https://github.com/googleapis/google-auth-library-python/issues/1332)) ([1a8f50c](https://github.com/googleapis/google-auth-library-python/commit/1a8f50c750669b12152b7758339600471c9e3e2a)) + + +### Bug Fixes + +* Pypy unit test build ([#1335](https://github.com/googleapis/google-auth-library-python/issues/1335)) ([33e4263](https://github.com/googleapis/google-auth-library-python/commit/33e4263e34d37db2713bfb3564f57fc10a991ee4)) + +## [2.20.0](https://github.com/googleapis/google-auth-library-python/compare/v2.19.1...v2.20.0) (2023-06-12) + + +### Features + +* Add public API load_credentials_from_dict ([#1326](https://github.com/googleapis/google-auth-library-python/issues/1326)) ([5467ad7](https://github.com/googleapis/google-auth-library-python/commit/5467ad75334ee0b5e23522679171cda5fd4edb8a)) + + +### Bug Fixes + +* Expiry in compute_engine.IDTokenCredentials ([#1327](https://github.com/googleapis/google-auth-library-python/issues/1327)) ([56a6159](https://github.com/googleapis/google-auth-library-python/commit/56a6159444467717f5a5e3c04aa678bd0a5881da)), closes [#1323](https://github.com/googleapis/google-auth-library-python/issues/1323) +* Expiry in impersonated_credentials.IDTokenCredentials ([#1330](https://github.com/googleapis/google-auth-library-python/issues/1330)) ([d1b887c](https://github.com/googleapis/google-auth-library-python/commit/d1b887c4bebbe4ad0df6d8f7eb6a6d50355a135d)) +* Invalid `dev` version identifiers in `setup.py` ([#1322](https://github.com/googleapis/google-auth-library-python/issues/1322)) ([a9b8f12](https://github.com/googleapis/google-auth-library-python/commit/a9b8f12db0c3ff4f84939646ba0777d21e68f572)), closes [#1321](https://github.com/googleapis/google-auth-library-python/issues/1321) + +## [2.19.1](https://github.com/googleapis/google-auth-library-python/compare/v2.19.0...v2.19.1) (2023-06-01) + + +### Bug Fixes + +* Check id token error response ([#1315](https://github.com/googleapis/google-auth-library-python/issues/1315)) ([2a71f7b](https://github.com/googleapis/google-auth-library-python/commit/2a71f7b2e9058efe5bfa42eaf58baa3228baf1bc)) +* Fix "AttributeError: 'str' object has no attribute 'get'" ([dac7cc3](https://github.com/googleapis/google-auth-library-python/commit/dac7cc3a126d8bd21cd20ccc536a2923f4cdecec)) + + +### Documentation + +* Replacing abc.com with example.com ([dac7cc3](https://github.com/googleapis/google-auth-library-python/commit/dac7cc3a126d8bd21cd20ccc536a2923f4cdecec)) + +## [2.19.0](https://github.com/googleapis/google-auth-library-python/compare/v2.18.1...v2.19.0) (2023-05-25) + + +### Features + +* Add metrics (part 1) ([#1298](https://github.com/googleapis/google-auth-library-python/issues/1298)) ([246dd07](https://github.com/googleapis/google-auth-library-python/commit/246dd079388e21036831e2ebc7586c9f596acbec)) +* Add metrics (part 2) ([#1303](https://github.com/googleapis/google-auth-library-python/issues/1303)) ([ebd5af7](https://github.com/googleapis/google-auth-library-python/commit/ebd5af7d372d4cde892601138de282217586135f)) +* Add metrics (part 3) ([#1305](https://github.com/googleapis/google-auth-library-python/issues/1305)) ([c7011b6](https://github.com/googleapis/google-auth-library-python/commit/c7011b6d3dba479390ce08a96ac1923de2a4e8b4)) +* Expose `universe_domain` for external account creds ([#1296](https://github.com/googleapis/google-auth-library-python/issues/1296)) ([ee07053](https://github.com/googleapis/google-auth-library-python/commit/ee070535ce06661eeb12e407e782c155b142cecf)) +* Remove python 2.7 from setup.py and nox tests ([#1301](https://github.com/googleapis/google-auth-library-python/issues/1301)) ([8437490](https://github.com/googleapis/google-auth-library-python/commit/84374903f418535d17811690632be9395403afaf)) + +## [2.18.1](https://github.com/googleapis/google-auth-library-python/compare/v2.18.0...v2.18.1) (2023-05-17) + + +### Bug Fixes + +* Self signed jwt token should be string type ([#1294](https://github.com/googleapis/google-auth-library-python/issues/1294)) ([17356fd](https://github.com/googleapis/google-auth-library-python/commit/17356fdd92da2123f989f67a5cebf82c78a3bc12)) + +## [2.18.0](https://github.com/googleapis/google-auth-library-python/compare/v2.17.3...v2.18.0) (2023-05-10) + + +### Features + +* Add smbios check to detect GCE residency ([#1276](https://github.com/googleapis/google-auth-library-python/issues/1276)) ([22d241b](https://github.com/googleapis/google-auth-library-python/commit/22d241b2060cc8fcc8c5005ccc8fbc932a7f66f6)) +* Universe domain support for service account ([#1286](https://github.com/googleapis/google-auth-library-python/issues/1286)) ([821c1b6](https://github.com/googleapis/google-auth-library-python/commit/821c1b65289a23855855f16b9c7a651fdbc1e4a9)) + +## [2.17.3](https://github.com/googleapis/google-auth-library-python/compare/v2.17.2...v2.17.3) (2023-04-12) + + +### Bug Fixes + +* Add useEmailAzp claim for id token iam flow ([#1270](https://github.com/googleapis/google-auth-library-python/issues/1270)) ([7a9c6f2](https://github.com/googleapis/google-auth-library-python/commit/7a9c6f2d90688e57583437c0872eb12dc5b0d833)) + +## [2.17.2](https://github.com/googleapis/google-auth-library-python/compare/v2.17.1...v2.17.2) (2023-04-05) + + +### Bug Fixes + +* Do not create new JWT credentials if they make the same claims as the existing. ([#1267](https://github.com/googleapis/google-auth-library-python/issues/1267)) ([eebb7b6](https://github.com/googleapis/google-auth-library-python/commit/eebb7b6630a7fc1e943a4d5f8fc76e9dd6dbe687)) + +## [2.17.1](https://github.com/googleapis/google-auth-library-python/compare/v2.17.0...v2.17.1) (2023-03-30) + + +### Bug Fixes + +* Print out reauth plugin error and raise if challenge output is None ([#1265](https://github.com/googleapis/google-auth-library-python/issues/1265)) ([08d22fe](https://github.com/googleapis/google-auth-library-python/commit/08d22fe805e0401c3a902ce96f9c3797e7f166b1)) + +## [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) + + +### Bug Fixes + +* Read both applicationId and relyingPartyId. ([#1246](https://github.com/googleapis/google-auth-library-python/issues/1246)) ([e125dfe](https://github.com/googleapis/google-auth-library-python/commit/e125dfe1e345bf5f6cef4efee8215de129401c51)) + +## [2.16.2](https://github.com/googleapis/google-auth-library-python/compare/v2.16.1...v2.16.2) (2023-03-02) + + +### Bug Fixes + +* Call gcloud config get project to get project for user cred ([#1243](https://github.com/googleapis/google-auth-library-python/issues/1243)) ([c078a13](https://github.com/googleapis/google-auth-library-python/commit/c078a13f3d7a6bda69efab11f11c1120e20137ef)) +* Do not use hardcoded string 'python', when you mean sys.executable. ([#1233](https://github.com/googleapis/google-auth-library-python/issues/1233)) ([91ac8e6](https://github.com/googleapis/google-auth-library-python/commit/91ac8e66396fd2335f2be6e7b40dc5c4f6e47bc2)) +* Don't retry if error or error_description is not string ([#1241](https://github.com/googleapis/google-auth-library-python/issues/1241)) ([e2d263a](https://github.com/googleapis/google-auth-library-python/commit/e2d263a2e79a35e8cc90aa338780d07c3313dc76)) +* Improve ADC related errors and warnings ([#1237](https://github.com/googleapis/google-auth-library-python/issues/1237)) ([2dfa213](https://github.com/googleapis/google-auth-library-python/commit/2dfa21371185340404d5d739723a8cd434886e02)) + +## [2.16.1](https://github.com/googleapis/google-auth-library-python/compare/v2.16.0...v2.16.1) (2023-02-17) + + +### Bug Fixes + +* Add support for python 3.11 ([#1212](https://github.com/googleapis/google-auth-library-python/issues/1212)) ([1fc95e3](https://github.com/googleapis/google-auth-library-python/commit/1fc95e3c3ecfbceb16c1be28725e8bc9eefe8bb0)) +* Remove 3PI config url validation ([#1220](https://github.com/googleapis/google-auth-library-python/issues/1220)) ([8b95515](https://github.com/googleapis/google-auth-library-python/commit/8b95515718d50b028c43ea9d6a7220489ffb5da0)) +* Update the docs generator interpreter to unblock documentation build ([#1218](https://github.com/googleapis/google-auth-library-python/issues/1218)) ([9d36c2f](https://github.com/googleapis/google-auth-library-python/commit/9d36c2f1f9e1eac8fbff4be504986dff5e7d4da2)) + +## [2.16.0](https://github.com/googleapis/google-auth-library-python/compare/v2.15.0...v2.16.0) (2023-01-09) + + +### Features + +* AwsCredentials should not call metadata server if security creds and region are retrievable through the environment variables ([#1195](https://github.com/googleapis/google-auth-library-python/issues/1195)) ([5e27c8f](https://github.com/googleapis/google-auth-library-python/commit/5e27c8f213b2e19ec504a04e1f95fc1333ea9e1e)) +* Wrap all python built-in exceptions into library excpetions ([#1191](https://github.com/googleapis/google-auth-library-python/issues/1191)) ([a83af39](https://github.com/googleapis/google-auth-library-python/commit/a83af399fe98764ee851997bf3078ec45a9b51c9)) + + +### Bug Fixes + +* Allow get_project_id to take a request ([#1203](https://github.com/googleapis/google-auth-library-python/issues/1203)) ([9a4d23a](https://github.com/googleapis/google-auth-library-python/commit/9a4d23a28eb4b9aa9e457ad053c087a0450eb298)) +* Make OAUTH2.0 client resistant to string type 'expires_in' responses from non-compliant services ([#1208](https://github.com/googleapis/google-auth-library-python/issues/1208)) ([9fc7b1c](https://github.com/googleapis/google-auth-library-python/commit/9fc7b1c5613366cc1ad7186f894cec26a5f2231e)) + +## [2.15.0](https://github.com/googleapis/google-auth-library-python/compare/v2.14.1...v2.15.0) (2022-12-01) + + +### Features + +* Add api_key credentials ([#1184](https://github.com/googleapis/google-auth-library-python/issues/1184)) ([370293e](https://github.com/googleapis/google-auth-library-python/commit/370293e84a14af0d6c6b34287bdcad020e0580e4)) +* Introduce a way to provide scopes granted by user ([#1189](https://github.com/googleapis/google-auth-library-python/issues/1189)) ([189f504](https://github.com/googleapis/google-auth-library-python/commit/189f504cbdfe043949688dfe55f3f449befad991)) + + +### Bug Fixes + +* Allow mtls sts endpoint for external account token urls. ([#1185](https://github.com/googleapis/google-auth-library-python/issues/1185)) ([c86dd69](https://github.com/googleapis/google-auth-library-python/commit/c86dd69cf79809e2d532a745a236db840fd8bc5d)) +* CI broken by removal of py.path ([#1194](https://github.com/googleapis/google-auth-library-python/issues/1194)) ([f719415](https://github.com/googleapis/google-auth-library-python/commit/f719415475e10e5af9ec75b3b13c57c25682bea0)) +* Ensure JWT segments have the right types ([#1162](https://github.com/googleapis/google-auth-library-python/issues/1162)) ([fc843cd](https://github.com/googleapis/google-auth-library-python/commit/fc843cd318e4ac4f40cf83bbcd7c6eae2b597ff8)) +* Updated the lower bound of interactive timeout and fix the kwarg… ([#1182](https://github.com/googleapis/google-auth-library-python/issues/1182)) ([50c0fd2](https://github.com/googleapis/google-auth-library-python/commit/50c0fd29a3b6a4fd6dc4b801d883f5d2b6de88c6)) + +## [2.14.1](https://github.com/googleapis/google-auth-library-python/compare/v2.14.0...v2.14.1) (2022-11-07) + + +### Bug Fixes + +* Apply quota project for compute cred in adc ([#1177](https://github.com/googleapis/google-auth-library-python/issues/1177)) ([b9aa92a](https://github.com/googleapis/google-auth-library-python/commit/b9aa92a1f1640f9c8bc4beb7b13051a01cb263a4)) +* Update minimum required version of cryptography in pyopenssl extra ([#1176](https://github.com/googleapis/google-auth-library-python/issues/1176)) ([e9e76d1](https://github.com/googleapis/google-auth-library-python/commit/e9e76d1ee4e4b39edcd6821ced7a5bc3ed60eba0)) +* Validate url domain for aws metadata urls ([#1174](https://github.com/googleapis/google-auth-library-python/issues/1174)) ([f9d7d77](https://github.com/googleapis/google-auth-library-python/commit/f9d7d77739db86870c9b87b003e8ce0c93d53e53)) + +## [2.14.0](https://github.com/googleapis/google-auth-library-python/compare/v2.13.0...v2.14.0) (2022-10-31) + + +### Features + +* Add token_info_url to external account credentials ([#1168](https://github.com/googleapis/google-auth-library-python/issues/1168)) ([9adee75](https://github.com/googleapis/google-auth-library-python/commit/9adee75712202234aa0b124a9ca0424654022428)) +* Read Quota Project from Environment Variable ([#1163](https://github.com/googleapis/google-auth-library-python/issues/1163)) ([57b3e42](https://github.com/googleapis/google-auth-library-python/commit/57b3e424927a5d86fbab8b231109a5aae1233745)) + + +### Bug Fixes + +* Adding more properties to external_account_authorized_user ([#1169](https://github.com/googleapis/google-auth-library-python/issues/1169)) ([a12b96d](https://github.com/googleapis/google-auth-library-python/commit/a12b96dcfa7cb58d9171fd7f2a7ea8331a228419)) + +## [2.13.0](https://github.com/googleapis/google-auth-library-python/compare/v2.12.0...v2.13.0) (2022-10-14) + + +### Features + +* Adds new external account authorized user credentials ([#1160](https://github.com/googleapis/google-auth-library-python/issues/1160)) ([523f811](https://github.com/googleapis/google-auth-library-python/commit/523f8117a72548d91f1bb169a3c91b095477ce3b)) +* Implement pluggable auth interactive mode ([#1131](https://github.com/googleapis/google-auth-library-python/issues/1131)) ([44a189f](https://github.com/googleapis/google-auth-library-python/commit/44a189fc6185bf33e9d5609cf8d57a846cd98aaf)) +* Introduce the functionality to override token_uri in credentials ([#1159](https://github.com/googleapis/google-auth-library-python/issues/1159)) ([73bc7e9](https://github.com/googleapis/google-auth-library-python/commit/73bc7e9e0e72b6c5057a13cdb4ac996b754ddb58)) + + +### Bug Fixes + +* Adding one more pattern to relax the regex check for sts and impersonation url endpoints ([#1158](https://github.com/googleapis/google-auth-library-python/issues/1158)) ([75326e3](https://github.com/googleapis/google-auth-library-python/commit/75326e397c619a2b58963d3fd9fc1a1a5eda13a0)) + +## [2.12.0](https://github.com/googleapis/google-auth-library-python/compare/v2.11.1...v2.12.0) (2022-09-26) + + +### Features + +* Retry behavior ([#1113](https://github.com/googleapis/google-auth-library-python/issues/1113)) ([78d3790](https://github.com/googleapis/google-auth-library-python/commit/78d37906f0811f9878834ac34d5b83e5cbd58800)) + + +### Bug Fixes + +* Modify RefreshError exception to use gcloud ADC command. ([#1149](https://github.com/googleapis/google-auth-library-python/issues/1149)) ([059fd35](https://github.com/googleapis/google-auth-library-python/commit/059fd353d5f2a8527de8bf1fe6dbd5e326c0e29a)) +* Revert "Update token refresh threshold from 20 seconds to 5 minutes". ([186464b](https://github.com/googleapis/google-auth-library-python/commit/186464bf5920fb3b76499ac542b0fb90023629de)) + +## [2.11.1](https://github.com/googleapis/google-auth-library-python/compare/v2.11.0...v2.11.1) (2022-09-20) + + +### Bug Fixes + +* Fix socket leak in impersonated_credentials ([#1123](https://github.com/googleapis/google-auth-library-python/issues/1123)) ([b1eb467](https://github.com/googleapis/google-auth-library-python/commit/b1eb467f50f0c080e89a122426061b28f0be0567)), closes [#1122](https://github.com/googleapis/google-auth-library-python/issues/1122) +* Make pluggable auth tests work in all environments ([#1114](https://github.com/googleapis/google-auth-library-python/issues/1114)) ([bb5c979](https://github.com/googleapis/google-auth-library-python/commit/bb5c9791c64e2472a90ba7191f79f4c5fedb2538)) +* Skip oauth2client adapter tests if oauth2client is not installed ([#1132](https://github.com/googleapis/google-auth-library-python/issues/1132)) ([d15092f](https://github.com/googleapis/google-auth-library-python/commit/d15092ff8b66b3039641d482a0debafde4ba0077)) +* Update token refresh threshold from 20 seconds to 5 minutes ([#1146](https://github.com/googleapis/google-auth-library-python/issues/1146)) ([261a561](https://github.com/googleapis/google-auth-library-python/commit/261a56138fba33ff7d898ab5907a6098125fefef)) + + +### Documentation + +* **samples:** Add auth samples and tests ([#1102](https://github.com/googleapis/google-auth-library-python/issues/1102)) ([ac87520](https://github.com/googleapis/google-auth-library-python/commit/ac875201bc8ba5d638a9eafcd3ccfdeb73a2f0ec)) + +## [2.11.0](https://github.com/googleapis/google-auth-library-python/compare/v2.10.0...v2.11.0) (2022-08-18) + + +### Features + +* add integration tests for configurable token lifespan ([#1103](https://github.com/googleapis/google-auth-library-python/issues/1103)) ([124bae6](https://github.com/googleapis/google-auth-library-python/commit/124bae60771a8984674a1d7eeab3ec22b2fa0033)) + + +### Bug Fixes + +* Async certificate retrieving ([#1101](https://github.com/googleapis/google-auth-library-python/issues/1101)) ([05f125d](https://github.com/googleapis/google-auth-library-python/commit/05f125def1205a14db52c870f2bfcef47f047206)) + +## [2.10.0](https://github.com/googleapis/google-auth-library-python/compare/v2.9.1...v2.10.0) (2022-08-05) + + +### Features + +* add integration tests for pluggable auth ([#1073](https://github.com/googleapis/google-auth-library-python/issues/1073)) ([f8d776a](https://github.com/googleapis/google-auth-library-python/commit/f8d776a290270da8c43b0f5ba8e8a1fabfcf4dd3)) +* support for configurable token lifetime ([0dc6a9a](https://github.com/googleapis/google-auth-library-python/commit/0dc6a9a30b994f20ad027bfc3715792aa97bd8af)) +* support for configurable token lifetime ([#1079](https://github.com/googleapis/google-auth-library-python/issues/1079)) ([0dc6a9a](https://github.com/googleapis/google-auth-library-python/commit/0dc6a9a30b994f20ad027bfc3715792aa97bd8af)) + + +### Bug Fixes + +* async certificate decoding ([#1085](https://github.com/googleapis/google-auth-library-python/issues/1085)) ([741c6c6](https://github.com/googleapis/google-auth-library-python/commit/741c6c6f5e2d4e98cbae1e6c7a9bc128c6a97bae)) +* Async system tests were not unwrapping async_generators ([#1086](https://github.com/googleapis/google-auth-library-python/issues/1086)) ([29d248a](https://github.com/googleapis/google-auth-library-python/commit/29d248acaf554c2bdba81c96999371c9e610c6b6)) +* Fix IDTokenCredentials update bug ([#1072](https://github.com/googleapis/google-auth-library-python/issues/1072)) ([b62c25c](https://github.com/googleapis/google-auth-library-python/commit/b62c25ca408f72d86fda35b611edb3d2c6eb4f85)) +* make expiration_time optional in response schema ([#1091](https://github.com/googleapis/google-auth-library-python/issues/1091)) ([032fb8d](https://github.com/googleapis/google-auth-library-python/commit/032fb8d1685a50081974ba85e6ead946f30a1ea8)) +* refactor credential subclass parameters ([#1095](https://github.com/googleapis/google-auth-library-python/issues/1095)) ([8d15f69](https://github.com/googleapis/google-auth-library-python/commit/8d15f69711f38196934eabff5f05be26b3afcbf6)) + +## [2.9.1](https://github.com/googleapis/google-auth-library-python/compare/v2.9.0...v2.9.1) (2022-07-12) + + +### Bug Fixes + +* there was a raise missing for throwing exceptions ([#1077](https://github.com/googleapis/google-auth-library-python/issues/1077)) ([d1f17b0](https://github.com/googleapis/google-auth-library-python/commit/d1f17b0ba71d90bff2402fde38bdbe51bc6481f9)) + +## [2.9.0](https://github.com/googleapis/google-auth-library-python/compare/v2.8.0...v2.9.0) (2022-06-28) + + +### Features + +* pluggable auth support ([#1045](https://github.com/googleapis/google-auth-library-python/issues/1045)) ([de14f4e](https://github.com/googleapis/google-auth-library-python/commit/de14f4e855c081c08f14cb0211a06107e5314bf7)) + +## [2.8.0](https://github.com/googleapis/google-auth-library-python/compare/v2.7.0...v2.8.0) (2022-06-14) + + +### Features + +* add experimental GDCH support ([#1044](https://github.com/googleapis/google-auth-library-python/issues/1044)) ([94fb5e2](https://github.com/googleapis/google-auth-library-python/commit/94fb5e27ef57b9ffb2fa58386bc0cb382ddafec2)) + +## [2.7.0](https://github.com/googleapis/google-auth-library-python/compare/v2.6.6...v2.7.0) (2022-06-07) + + +### Features + +* add experimental enterprise cert support ([#1052](https://github.com/googleapis/google-auth-library-python/issues/1052)) ([dda7dda](https://github.com/googleapis/google-auth-library-python/commit/dda7ddaf859f5f7a21af714ebb422dfde4da46c8)) +* add experimental GDCH support ([#1022](https://github.com/googleapis/google-auth-library-python/issues/1022)) ([5367aac](https://github.com/googleapis/google-auth-library-python/commit/5367aac881fdba814f66e4d6d5f59fccecc12547)) +* Pluggable auth support ([#995](https://github.com/googleapis/google-auth-library-python/issues/995)) ([62daa73](https://github.com/googleapis/google-auth-library-python/commit/62daa73168f47806905bfc52b8f059995a193b71)) + + +### Bug Fixes + +* validate urls for external accounts ([#1031](https://github.com/googleapis/google-auth-library-python/issues/1031)) ([61b1f15](https://github.com/googleapis/google-auth-library-python/commit/61b1f1561ad6c3ddf4540143171351e53ff50f99)) + + +### Reverts + +* pluggable auth support [#995](https://github.com/googleapis/google-auth-library-python/issues/995) ([#1039](https://github.com/googleapis/google-auth-library-python/issues/1039)) ([513d999](https://github.com/googleapis/google-auth-library-python/commit/513d999d1f3b8a69bff86a2b91a73b6bdf6f92d0)) +* revert experimental GDCH support ([#1022](https://github.com/googleapis/google-auth-library-python/issues/1022)) ([#1042](https://github.com/googleapis/google-auth-library-python/issues/1042)) ([c720995](https://github.com/googleapis/google-auth-library-python/commit/c720995aa08f539fe884685d9d53e599ca707e45)) + + +### Documentation + +* fix changelog header to consistent size ([#1046](https://github.com/googleapis/google-auth-library-python/issues/1046)) ([e64d084](https://github.com/googleapis/google-auth-library-python/commit/e64d0847276d456a95b171f5b79207e94ab818f3)) + +## [2.6.6](https://github.com/googleapis/google-auth-library-python/compare/v2.6.5...v2.6.6) (2022-04-21) + + +### Bug Fixes + +* silence TypeError during tear down stage ([#1027](https://github.com/googleapis/google-auth-library-python/issues/1027)) ([952a6aa](https://github.com/googleapis/google-auth-library-python/commit/952a6aad888140c13815aada95f33792e414e061)) + +## [2.6.5](https://github.com/googleapis/google-auth-library-python/compare/v2.6.4...v2.6.5) (2022-04-14) + + +### Bug Fixes + +* add additional missing import in _default.py ([#1018](https://github.com/googleapis/google-auth-library-python/issues/1018)) ([638331b](https://github.com/googleapis/google-auth-library-python/commit/638331b5b89c807b40c23c1e6333845d9b7e169a)) + +## [2.6.4](https://github.com/googleapis/google-auth-library-python/compare/v2.6.3...v2.6.4) (2022-04-12) + + +### Bug Fixes + +* fix missing import in _default.py ([#1015](https://github.com/googleapis/google-auth-library-python/issues/1015)) ([63f4e38](https://github.com/googleapis/google-auth-library-python/commit/63f4e38153ded9fe9b51b83a1de74f3b71f73abc)) + +## [2.6.3](https://github.com/googleapis/google-auth-library-python/compare/v2.6.2...v2.6.3) (2022-04-06) + + +### Bug Fixes + +* change requests lib import place ([#1010](https://github.com/googleapis/google-auth-library-python/issues/1010)) ([c753c08](https://github.com/googleapis/google-auth-library-python/commit/c753c08d2c78295173bb1160e8a74e819a352c33)) +* clean up HTTP session and pool during tear down phase ([#1007](https://github.com/googleapis/google-auth-library-python/issues/1007)) ([d057376](https://github.com/googleapis/google-auth-library-python/commit/d057376245283402fe0b772ca138091c05864e5d)) +* pin click version and update sys test creds ([#1008](https://github.com/googleapis/google-auth-library-python/issues/1008)) ([ae2804b](https://github.com/googleapis/google-auth-library-python/commit/ae2804bf292b5c8e6f935d2d0751db8fbe95a7b3)) + +## [2.6.2](https://github.com/googleapis/google-auth-library-python/compare/v2.6.1...v2.6.2) (2022-03-16) + + +### Bug Fixes + +* Rename aws imdsv2 url field and update token lifetime ([#982](https://github.com/googleapis/google-auth-library-python/issues/982)) ([818e6d2](https://github.com/googleapis/google-auth-library-python/commit/818e6d2e63e58601499f0eaac1dd160345d9d6e4)) + + +### Miscellaneous Chores + +* let release-please finish the release ([#991](https://github.com/googleapis/google-auth-library-python/issues/991)) ([d2bdc9a](https://github.com/googleapis/google-auth-library-python/commit/d2bdc9a8a23930a01e5b8445e869a135511977cf)) + +## [2.6.1](https://github.com/googleapis/google-auth-library-python/compare/v2.6.0...v2.6.1) (2022-02-09) + + +### Bug Fixes + +* Add AWS session token to metadata requests ([#958](https://github.com/googleapis/google-auth-library-python/issues/958)) ([5c7f734](https://github.com/googleapis/google-auth-library-python/commit/5c7f7342179d007e9e779ffe8734d540cdf36fde)) + +## [2.6.0](https://github.com/googleapis/google-auth-library-python/compare/v2.5.0...v2.6.0) (2022-01-31) + + +### Features + +* ADC can load an impersonated service account credentials. ([#962](https://github.com/googleapis/google-auth-library-python/issues/962)) ([52c8ef9](https://github.com/googleapis/google-auth-library-python/commit/52c8ef90058120d7d04d3d201adc111664be526c)) + + +### Bug Fixes + +* revert "feat: add api key support ([#826](https://github.com/googleapis/google-auth-library-python/issues/826))" ([#964](https://github.com/googleapis/google-auth-library-python/issues/964)) ([f9f23f4](https://github.com/googleapis/google-auth-library-python/commit/f9f23f4370f2a7a5b2c66ee56a5e700ef03b5b06)) + +## [2.5.0](https://github.com/googleapis/google-auth-library-python/compare/v2.4.1...v2.5.0) (2022-01-25) + + +### Features + +* ADC can load an impersonated service account credentials. ([#956](https://github.com/googleapis/google-auth-library-python/issues/956)) ([a8eb4c8](https://github.com/googleapis/google-auth-library-python/commit/a8eb4c8693055a3420cfe9c3420aae2bc8cd465a)) + +## [2.4.1](https://github.com/googleapis/google-auth-library-python/compare/v2.4.0...v2.4.1) (2022-01-21) + + +### Bug Fixes + +* urllib3 import ([#953](https://github.com/googleapis/google-auth-library-python/issues/953)) ([c8b5cae](https://github.com/googleapis/google-auth-library-python/commit/c8b5cae3da5eb9d40067d38dac51a4a8c1e0763e)) + +## [2.4.0](https://github.com/googleapis/google-auth-library-python/compare/v2.3.3...v2.4.0) (2022-01-20) + + +### Features + +* add 'py.typed' declaration ([#919](https://github.com/googleapis/google-auth-library-python/issues/919)) ([c993504](https://github.com/googleapis/google-auth-library-python/commit/c99350455d0f7fd3aab950ac47b43000c73dd312)) +* add api key support ([#826](https://github.com/googleapis/google-auth-library-python/issues/826)) ([3b15092](https://github.com/googleapis/google-auth-library-python/commit/3b15092b3461278400e4683060f64a96d50587c4)) + + +### Bug Fixes + +* **deps:** allow cachetools 5.0 for python 3.7+ ([#937](https://github.com/googleapis/google-auth-library-python/issues/937)) ([1eae37d](https://github.com/googleapis/google-auth-library-python/commit/1eae37db7f6fceb32d6ef0041962ce1755d2116c)) +* fix the message format for metadata server exception ([#916](https://github.com/googleapis/google-auth-library-python/issues/916)) ([e756f08](https://github.com/googleapis/google-auth-library-python/commit/e756f08dc78616040ab8fbd7db20903137ccf0c7)) + + +### Documentation + +* fix intersphinx link for 'requests-oauthlib' ([#921](https://github.com/googleapis/google-auth-library-python/issues/921)) ([967be4f](https://github.com/googleapis/google-auth-library-python/commit/967be4f4e2a43ba7e240d7acb01b6b992d40e6ec)) +* note ValueError in `verify_oauth2_token` ([#928](https://github.com/googleapis/google-auth-library-python/issues/928)) ([82bc5f0](https://github.com/googleapis/google-auth-library-python/commit/82bc5f08111de78a2b475b0310d3f35470680dbe)) + +## [2.3.3](https://www.github.com/googleapis/google-auth-library-python/compare/v2.3.2...v2.3.3) (2021-11-01) + + +### Bug Fixes + +* add fetch_id_token_credentials ([#866](https://www.github.com/googleapis/google-auth-library-python/issues/866)) ([8f1e9cf](https://www.github.com/googleapis/google-auth-library-python/commit/8f1e9cfd56dbaae0dff64499e1d0cf55abc5b97e)) +* fix error in sign_bytes ([#905](https://www.github.com/googleapis/google-auth-library-python/issues/905)) ([ef31284](https://www.github.com/googleapis/google-auth-library-python/commit/ef3128474431b07d1d519209ea61622bc245ce91)) +* use 'int.to_bytes' and 'int.from_bytes' for py3 ([#904](https://www.github.com/googleapis/google-auth-library-python/issues/904)) ([bd0ccc5](https://www.github.com/googleapis/google-auth-library-python/commit/bd0ccc5fe77d55f7a19f5278d6b60587c393ee3c)) + +## [2.3.2](https://www.github.com/googleapis/google-auth-library-python/compare/v2.3.1...v2.3.2) (2021-10-26) + + +### Bug Fixes + +* add clock_skew_in_seconds to verify_token functions ([#894](https://www.github.com/googleapis/google-auth-library-python/issues/894)) ([8e95c1e](https://www.github.com/googleapis/google-auth-library-python/commit/8e95c1e458793593972b6b05a355aaeaecd31670)) + +## [2.3.1](https://www.github.com/googleapis/google-auth-library-python/compare/v2.3.0...v2.3.1) (2021-10-21) + + +### Bug Fixes + +* add back python 2.7 for gcloud usage only ([#892](https://www.github.com/googleapis/google-auth-library-python/issues/892)) ([5bd5ccf](https://www.github.com/googleapis/google-auth-library-python/commit/5bd5ccf7cf229f033c7152ce0b650a40feb25f81)) + + +### Documentation + +* Fix formatting of `GCE_METADATA_HOST` ([#890](https://www.github.com/googleapis/google-auth-library-python/issues/890)) ([e2b3c98](https://www.github.com/googleapis/google-auth-library-python/commit/e2b3c98cd8c67b702be1b711c06ee7b9bbedb8ba)) + +## [2.3.0](https://www.github.com/googleapis/google-auth-library-python/compare/v2.2.1...v2.3.0) (2021-10-07) + + +### Features + +* add support for Python 3.10 ([#882](https://www.github.com/googleapis/google-auth-library-python/issues/882)) ([19d41f8](https://www.github.com/googleapis/google-auth-library-python/commit/19d41f8ec94ab0148d2f09a5d560ae237a87ffdb)) + + +### Bug Fixes + +* ADC with impersonated workforce pools ([#877](https://www.github.com/googleapis/google-auth-library-python/issues/877)) ([10bd9fb](https://www.github.com/googleapis/google-auth-library-python/commit/10bd9fbecd462435246afa46fd666a2836cd9e89)) + +## [2.2.1](https://www.github.com/googleapis/google-auth-library-python/compare/v2.2.0...v2.2.1) (2021-09-28) + + +### Bug Fixes + +* disable self signed jwt for domain wide delegation ([#873](https://www.github.com/googleapis/google-auth-library-python/issues/873)) ([0cd15e2](https://www.github.com/googleapis/google-auth-library-python/commit/0cd15e2ae20f7caddf9eb2d069064058d3c14ad7)) + +## [2.2.0](https://www.github.com/googleapis/google-auth-library-python/compare/v2.1.0...v2.2.0) (2021-09-21) + + +### Features + +* add support for workforce pool credentials ([#868](https://www.github.com/googleapis/google-auth-library-python/issues/868)) ([993bab2](https://www.github.com/googleapis/google-auth-library-python/commit/993bab2aaacf3034e09d9f0f25d36c0e815d3a29)) + +## [2.1.0](https://www.github.com/googleapis/google-auth-library-python/compare/v2.0.2...v2.1.0) (2021-09-10) + + +### Features + +* Improve handling of clock skew ([#858](https://www.github.com/googleapis/google-auth-library-python/issues/858)) ([45c4491](https://www.github.com/googleapis/google-auth-library-python/commit/45c4491fb971c9edf590b27b9e271b7a23a1bba6)) + + +### Bug Fixes + +* add SAML challenge to reauth ([#819](https://www.github.com/googleapis/google-auth-library-python/issues/819)) ([13aed5f](https://www.github.com/googleapis/google-auth-library-python/commit/13aed5ffe3ba435004ab48202462452f04d7cb29)) +* disable warning if quota project id provided to auth.default() ([#856](https://www.github.com/googleapis/google-auth-library-python/issues/856)) ([11ebaeb](https://www.github.com/googleapis/google-auth-library-python/commit/11ebaeb9d7c0862916154cfb810238574507629a)) +* rename CLOCK_SKEW and separate client/server user case ([#863](https://www.github.com/googleapis/google-auth-library-python/issues/863)) ([738611b](https://www.github.com/googleapis/google-auth-library-python/commit/738611bd2914f0fd5fa8b49b65f56ef321829c85)) + +## [2.0.2](https://www.github.com/googleapis/google-auth-library-python/compare/v2.0.1...v2.0.2) (2021-08-25) + + +### Bug Fixes + +* use 'int.to_bytes' rather than deprecated crypto wrapper ([#848](https://www.github.com/googleapis/google-auth-library-python/issues/848)) ([b79b554](https://www.github.com/googleapis/google-auth-library-python/commit/b79b55407b31933c9a8fe6de01478fa00a33fa2b)) +* use int.from_bytes ([#846](https://www.github.com/googleapis/google-auth-library-python/issues/846)) ([466aed9](https://www.github.com/googleapis/google-auth-library-python/commit/466aed99f5c2ba15d2036fa21cc83b3f0fc22639)) + +## [2.0.1](https://www.github.com/googleapis/google-auth-library-python/compare/v2.0.0...v2.0.1) (2021-08-17) + + +### Bug Fixes + +* normalize AWS paths correctly on windows ([#842](https://www.github.com/googleapis/google-auth-library-python/issues/842)) ([4e0fb1c](https://www.github.com/googleapis/google-auth-library-python/commit/4e0fb1cee78ee56b878b6e12be3b3c58df242b05)) + +## [2.0.0](https://www.github.com/googleapis/google-auth-library-python/compare/v2.0.0-b1...v2.0.0) (2021-08-16) + + +### ⚠ BREAKING CHANGES +* drop support for Python 2.7 ([#778](https://www.github.com/googleapis/google-auth-library-python/issues/778)) ([560cf1e](https://www.github.com/googleapis/google-auth-library-python/commit/560cf1ed02a900436c5d9e0a0fb3f94b5fd98c55)) + + +### Features + +* service account is able to use a private token endpoint ([#835](https://www.github.com/googleapis/google-auth-library-python/issues/835)) ([20b817a](https://www.github.com/googleapis/google-auth-library-python/commit/20b817af8e202b0331998e5abde4e2a5aab51f9a)) + + +### Bug Fixes + +* downscoping documentation bugs ([#830](https://www.github.com/googleapis/google-auth-library-python/issues/830)) ([da8bb13](https://www.github.com/googleapis/google-auth-library-python/commit/da8bb13c1349e771ffc2e125256030495c53d956)) +* Fix missing space in error message. ([#821](https://www.github.com/googleapis/google-auth-library-python/issues/821)) ([7b03988](https://www.github.com/googleapis/google-auth-library-python/commit/7b039888aeb6ec7691d91c9afce182b17f02b1a6)) + + +### Documentation + +* update user guide/references for downscoped creds ([#827](https://www.github.com/googleapis/google-auth-library-python/issues/827)) ([d1840dc](https://www.github.com/googleapis/google-auth-library-python/commit/d1840dcdcd03dfd7fdfa81d08da68402f6f8b658)) + +## [2.0.0b1](https://www.github.com/googleapis/google-auth-library-python/compare/v1.34.0...v2.0.0b1) (2021-08-03) + + +### ⚠ BREAKING CHANGES + +* drop support for Python 2.7 ([#778](https://www.github.com/googleapis/google-auth-library-python/issues/778)) ([560cf1e](https://www.github.com/googleapis/google-auth-library-python/commit/560cf1ed02a900436c5d9e0a0fb3f94b5fd98c55)) + +## [1.34.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.33.1...v1.34.0) (2021-07-23) + + +### Features + +* support refresh callable on google.oauth2.credentials.Credentials ([#812](https://www.github.com/googleapis/google-auth-library-python/issues/812)) ([ec2fb18](https://www.github.com/googleapis/google-auth-library-python/commit/ec2fb18e7f0f452fb20e43fd0bfbb788bcf7f46b)) + + +### Bug Fixes + +* do not use the GAE APIs on gen2+ runtimes ([#807](https://www.github.com/googleapis/google-auth-library-python/issues/807)) ([7f7d92d](https://www.github.com/googleapis/google-auth-library-python/commit/7f7d92d63ffee91859fc819416af78cef3baf574)) + +## [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) + + +### Features + +* define `CredentialAccessBoundary` classes ([#793](https://www.github.com/googleapis/google-auth-library-python/issues/793)) ([d883921](https://www.github.com/googleapis/google-auth-library-python/commit/d883921ae8fdc92b2c2cf1b3a5cd389e1287eb60)) +* define `google.auth.downscoped.Credentials` class ([#801](https://www.github.com/googleapis/google-auth-library-python/issues/801)) ([2f5c3a6](https://www.github.com/googleapis/google-auth-library-python/commit/2f5c3a636192c20cf4c92c3831d1f485031d24d2)) +* service account is able to use a private token endpoint ([#784](https://www.github.com/googleapis/google-auth-library-python/issues/784)) ([0e26409](https://www.github.com/googleapis/google-auth-library-python/commit/0e264092e35ac02ad68d5d91424ecba5397daa41)) + + +### Bug Fixes + +* fix fetch_id_token credential lookup order to match adc ([#748](https://www.github.com/googleapis/google-auth-library-python/issues/748)) ([c34452e](https://www.github.com/googleapis/google-auth-library-python/commit/c34452ef450c42cfef37a1b0c548bb422302dd5d)) + + +### Documentation + +* fix code block formatting in 'user-guide.rst' ([#794](https://www.github.com/googleapis/google-auth-library-python/issues/794)) ([4fd84bd](https://www.github.com/googleapis/google-auth-library-python/commit/4fd84bdf43694af5107dc8c8b443c06ba2f61d2c)) + +## [1.32.1](https://www.github.com/googleapis/google-auth-library-python/compare/v1.32.0...v1.32.1) (2021-06-30) + + +### Bug Fixes + +* avoid leaking sub-session created for '_auth_request' ([#789](https://www.github.com/googleapis/google-auth-library-python/issues/789)) ([2079ab5](https://www.github.com/googleapis/google-auth-library-python/commit/2079ab5e1db464f502248ae4f9e424deeef87fb2)) + +## [1.32.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.31.0...v1.32.0) (2021-06-16) + + +### Features + +* allow scopes for self signed jwt ([#776](https://www.github.com/googleapis/google-auth-library-python/issues/776)) ([2cfe655](https://www.github.com/googleapis/google-auth-library-python/commit/2cfe655bba837170abc07701557a1a5e0fe3294e)) + +## [1.31.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.30.2...v1.31.0) (2021-06-09) + + +### Features + +* define useful properties on `google.auth.external_account.Credentials` ([#770](https://www.github.com/googleapis/google-auth-library-python/issues/770)) ([f97499c](https://www.github.com/googleapis/google-auth-library-python/commit/f97499c718af70d17c17e0c58d6381273eceabcd)) + + +### Bug Fixes + +* avoid deleting items while iterating ([#772](https://www.github.com/googleapis/google-auth-library-python/issues/772)) ([a5e6b65](https://www.github.com/googleapis/google-auth-library-python/commit/a5e6b651aa8ad407ce087fe32f40b46925bae527)) + +## [1.30.2](https://www.github.com/googleapis/google-auth-library-python/compare/v1.30.1...v1.30.2) (2021-06-03) + + +### Bug Fixes + +* **dependencies:** add urllib3 and requests to aiohttp extra ([#755](https://www.github.com/googleapis/google-auth-library-python/issues/755)) ([a923442](https://www.github.com/googleapis/google-auth-library-python/commit/a9234423cb2b69068fc0d30a5a0ee86a599ab8b7)) +* enforce constraints during unit tests ([#760](https://www.github.com/googleapis/google-auth-library-python/issues/760)) ([1a6496a](https://www.github.com/googleapis/google-auth-library-python/commit/1a6496abfc17ab781bfa485dc74d0f7dbbe0c44b)), closes [#759](https://www.github.com/googleapis/google-auth-library-python/issues/759) +* session object was never used in aiohttp request ([#700](https://www.github.com/googleapis/google-auth-library-python/issues/700)) ([#701](https://www.github.com/googleapis/google-auth-library-python/issues/701)) ([09e0389](https://www.github.com/googleapis/google-auth-library-python/commit/09e0389db72cc9d6c5dde34864cb54d717dc0b92)) + +## [1.30.1](https://www.github.com/googleapis/google-auth-library-python/compare/v1.30.0...v1.30.1) (2021-05-20) + + +### Bug Fixes + +* allow user to customize context aware metadata path in _mtls_helper ([#754](https://www.github.com/googleapis/google-auth-library-python/issues/754)) ([e697687](https://www.github.com/googleapis/google-auth-library-python/commit/e6976879b392508c022610ab3ea2ea55c7089c63)) +* fix function name in signing error message ([#751](https://www.github.com/googleapis/google-auth-library-python/issues/751)) ([e9ca25f](https://www.github.com/googleapis/google-auth-library-python/commit/e9ca25fa39a112cc1a376388ab47a4e1b3ea746c)) + +## [1.30.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.29.0...v1.30.0) (2021-04-23) + + +### Features + +* add reauth support to async user credentials for gcloud ([#738](https://www.github.com/googleapis/google-auth-library-python/issues/738)) ([9e10823](https://www.github.com/googleapis/google-auth-library-python/commit/9e1082366d113286bc063051fd76b4799791d943)). This internal feature is for gcloud developers only. + +## [1.29.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.28.1...v1.29.0) (2021-04-15) + + +### Features + +* add reauth feature to user credentials for gcloud ([#727](https://www.github.com/googleapis/google-auth-library-python/issues/727)) ([82293fe](https://www.github.com/googleapis/google-auth-library-python/commit/82293fe2caaf5258babb5df1cff0a5ddc9e44b38)). This internal feature is for gcloud developers only. + + +### Bug Fixes + +* Allow multiple audiences for id_token.verify_token ([#733](https://www.github.com/googleapis/google-auth-library-python/issues/733)) ([56c3946](https://www.github.com/googleapis/google-auth-library-python/commit/56c394680ac6dfc07c611a9eb1e030e32edd4fe1)) + +## [1.28.1](https://www.github.com/googleapis/google-auth-library-python/compare/v1.28.0...v1.28.1) (2021-04-08) + + +### Bug Fixes + +* support custom alg in jwt header for signing ([#729](https://www.github.com/googleapis/google-auth-library-python/issues/729)) ([0a83706](https://www.github.com/googleapis/google-auth-library-python/commit/0a83706c9d65f7d5a30ea3b42c5beac269ed2a25)) + +## [1.28.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.27.1...v1.28.0) (2021-03-16) + + +### Features + +* allow the AWS_DEFAULT_REGION environment variable ([#721](https://www.github.com/googleapis/google-auth-library-python/issues/721)) ([199da47](https://www.github.com/googleapis/google-auth-library-python/commit/199da4781029916dc075738ec7bd173bd89abe54)) +* expose library version at `google.auth.__version` ([#683](https://www.github.com/googleapis/google-auth-library-python/issues/683)) ([a2cbc32](https://www.github.com/googleapis/google-auth-library-python/commit/a2cbc3245460e1ae1d310de6a2a4007d5a3a06b7)) + + +### Bug Fixes + +* fix unit tests so they can work in g3 ([#714](https://www.github.com/googleapis/google-auth-library-python/issues/714)) ([d80c85f](https://www.github.com/googleapis/google-auth-library-python/commit/d80c85f285ae1a44ddc5a5d94a66e065a79f6d19)) + +## [1.27.1](https://www.github.com/googleapis/google-auth-library-python/compare/v1.27.0...v1.27.1) (2021-02-26) + + +### Bug Fixes + +* ignore gcloud warning when getting project id ([#708](https://www.github.com/googleapis/google-auth-library-python/issues/708)) ([3f2f3ea](https://www.github.com/googleapis/google-auth-library-python/commit/3f2f3eaf09006d3d0ec9c030d359114238479279)) +* use gcloud creds flow ([#705](https://www.github.com/googleapis/google-auth-library-python/issues/705)) ([333cb76](https://www.github.com/googleapis/google-auth-library-python/commit/333cb765b52028329ec3ca04edf32c5764b1db68)) + +## [1.27.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.26.1...v1.27.0) (2021-02-16) + + +### Features + +* workload identity federation support ([#698](https://www.github.com/googleapis/google-auth-library-python/issues/698)) ([d4d7f38](https://www.github.com/googleapis/google-auth-library-python/commit/d4d7f3815e0cea3c9f39a5204a4f001de99568e9)) + + +### Bug Fixes + +* add pyopenssl as extra dependency ([#697](https://www.github.com/googleapis/google-auth-library-python/issues/697)) ([aeab5d0](https://www.github.com/googleapis/google-auth-library-python/commit/aeab5d07c5538f3d8cce817df24199534572b97d)) + +## [1.26.1](https://www.github.com/googleapis/google-auth-library-python/compare/v1.26.0...v1.26.1) (2021-02-11) + + +### Documentation + +* fix a typo in the user guide (avaiable -> available) ([#680](https://www.github.com/googleapis/google-auth-library-python/issues/680)) ([684457a](https://www.github.com/googleapis/google-auth-library-python/commit/684457afd3f81892e12d983a61672d7ea9bbe296)) + +### Bug Fixes + +* revert workload identity federation support ([#691](https://github.com/googleapis/google-auth-library-python/pull/691)) + +## [1.26.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.25.0...v1.26.0) (2021-02-09) + + +### Features + +* workload identity federation support ([#686](https://www.github.com/googleapis/google-auth-library-python/issues/686)) ([5dcd2b1](https://www.github.com/googleapis/google-auth-library-python/commit/5dcd2b1bdd9d21522636d959cffc49ee29dda88f)) + +## [1.25.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.24.0...v1.25.0) (2021-02-03) + + +### Features + +* support self-signed jwt in requests and urllib3 transports ([#679](https://www.github.com/googleapis/google-auth-library-python/issues/679)) ([7a94acb](https://www.github.com/googleapis/google-auth-library-python/commit/7a94acb50e75fe0a51688e0f968bca3fa9bd9082)) +* use self-signed jwt for service account ([#665](https://www.github.com/googleapis/google-auth-library-python/issues/665)) ([bf5ce0c](https://www.github.com/googleapis/google-auth-library-python/commit/bf5ce0c56c10f655ced6630653f0f2ad47fcceeb)) + +## [1.24.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.23.0...v1.24.0) (2020-12-11) + + +### Features + +* add Python 3.9 support, drop Python 3.5 support ([#655](https://www.github.com/googleapis/google-auth-library-python/issues/655)) ([6de753d](https://www.github.com/googleapis/google-auth-library-python/commit/6de753d585254c813b3e6cbde27bf5466261ba10)), closes [#654](https://www.github.com/googleapis/google-auth-library-python/issues/654) + + +### Bug Fixes + +* avoid losing the original '_include_email' parameter in impersonated credentials ([#626](https://www.github.com/googleapis/google-auth-library-python/issues/626)) ([fd9b5b1](https://www.github.com/googleapis/google-auth-library-python/commit/fd9b5b10c80950784bd37ee56e32c505acb5078d)) + + +### Documentation + +* fix typo in import ([#651](https://www.github.com/googleapis/google-auth-library-python/issues/651)) ([3319ea8](https://www.github.com/googleapis/google-auth-library-python/commit/3319ea8ae876c73a94f51237b3bbb3f5df2aef89)), closes [#650](https://www.github.com/googleapis/google-auth-library-python/issues/650) + +## [1.23.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.22.1...v1.23.0) (2020-10-29) + + +### Features + +* Add custom scopes for access tokens from the metadata service ([#633](https://www.github.com/googleapis/google-auth-library-python/issues/633)) ([0323cf3](https://www.github.com/googleapis/google-auth-library-python/commit/0323cf390b16e8483660ac88775e8ea4e7f7702d)) + + +### Bug Fixes + +* **deps:** Revert "fix: pin 'aoihttp < 3.7.0dev' ([#634](https://www.github.com/googleapis/google-auth-library-python/issues/634))" ([#632](https://www.github.com/googleapis/google-auth-library-python/issues/632)) ([#640](https://www.github.com/googleapis/google-auth-library-python/issues/640)) ([b790e65](https://www.github.com/googleapis/google-auth-library-python/commit/b790e6535cc37591b23866027a426cde312e07c1)) +* pin 'aoihttp < 3.7.0dev' ([#634](https://www.github.com/googleapis/google-auth-library-python/issues/634)) ([05f9524](https://www.github.com/googleapis/google-auth-library-python/commit/05f95246fab928fe2f445781117eeac8088497fb)) +* remove checks for ancient versions of Cryptography ([#596](https://www.github.com/googleapis/google-auth-library-python/issues/596)) ([6407258](https://www.github.com/googleapis/google-auth-library-python/commit/6407258956ec42e3b722418cb7f366e5ae9272ec)), closes [/github.com/googleapis/google-auth-library-python/issues/595#issuecomment-683903062](https://www.github.com/googleapis//github.com/googleapis/google-auth-library-python/issues/595/issues/issuecomment-683903062) + +## [1.22.1](https://www.github.com/googleapis/google-auth-library-python/compare/v1.22.0...v1.22.1) (2020-10-05) + + +### Bug Fixes + +* move aiohttp to extra as it is currently internal surface ([#619](https://www.github.com/googleapis/google-auth-library-python/issues/619)) ([a924011](https://www.github.com/googleapis/google-auth-library-python/commit/a9240111e7af29338624d98ee10aed31462f4d19)), closes [#618](https://www.github.com/googleapis/google-auth-library-python/issues/618) + +## [1.22.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.21.3...v1.22.0) (2020-09-28) + + +### Features + +* add asyncio based auth flow ([#612](https://www.github.com/googleapis/google-auth-library-python/issues/612)) ([7e15258](https://www.github.com/googleapis/google-auth-library-python/commit/7e1525822d51bd9ce7dffca42d71313e6e776fcd)), closes [#572](https://www.github.com/googleapis/google-auth-library-python/issues/572) + +## [1.21.3](https://www.github.com/googleapis/google-auth-library-python/compare/v1.21.2...v1.21.3) (2020-09-22) + + +### Bug Fixes + +* fix expiry for `to_json()` ([#589](https://www.github.com/googleapis/google-auth-library-python/issues/589)) ([d0e0aba](https://www.github.com/googleapis/google-auth-library-python/commit/d0e0aba0a9f665268ffa1b22d44f4bd7e9b449d6)), closes [/github.com/googleapis/oauth2client/blob/master/oauth2client/client.py#L55](https://www.github.com/googleapis//github.com/googleapis/oauth2client/blob/master/oauth2client/client.py/issues/L55) + +## [1.21.2](https://www.github.com/googleapis/google-auth-library-python/compare/v1.21.1...v1.21.2) (2020-09-08) + + +### Bug Fixes + +* migrate signBlob to iamcredentials.googleapis.com ([#600](https://www.github.com/googleapis/google-auth-library-python/issues/600)) ([694d83f](https://www.github.com/googleapis/google-auth-library-python/commit/694d83fd23c0e8c2fde27136d1b3f8f6db6338a6)) + +## [1.21.1](https://www.github.com/googleapis/google-auth-library-python/compare/v1.21.0...v1.21.1) (2020-09-03) + + +### Bug Fixes + +* dummy commit to trigger a auto release ([#597](https://www.github.com/googleapis/google-auth-library-python/issues/597)) ([d32f7df](https://www.github.com/googleapis/google-auth-library-python/commit/d32f7df4895122ef23b664672d7db3f58d9b7d36)) + +## [1.21.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.20.1...v1.21.0) (2020-08-27) + + +### Features + +* add GOOGLE_API_USE_CLIENT_CERTIFICATE support ([#592](https://www.github.com/googleapis/google-auth-library-python/issues/592)) ([c0c995f](https://www.github.com/googleapis/google-auth-library-python/commit/c0c995f3de237a2346b59797ee7c4d44ff2a197c)) + +## [1.20.1](https://www.github.com/googleapis/google-auth-library-python/compare/v1.20.0...v1.20.1) (2020-08-06) + + +### Bug Fixes + +* reduce refresh clock skew to 10 seconds ([#581](https://www.github.com/googleapis/google-auth-library-python/issues/581)) ([42321ba](https://www.github.com/googleapis/google-auth-library-python/commit/42321bafd38a8bd806f4d01bfa0eda3b5a961667)) +* set Content-Type header in the request to signBlob API to avoid Invalid JSON payload error ([#439](https://www.github.com/googleapis/google-auth-library-python/issues/439)) ([20f82e2](https://www.github.com/googleapis/google-auth-library-python/commit/20f82e22b7e8c6c7fdd29e08eaf7b4cf2abdcf37)) + +## [1.20.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.19.2...v1.20.0) (2020-07-23) + + +### Features + +* Add debug logging that can help with diagnosing auth lib. path ([#473](https://www.github.com/googleapis/google-auth-library-python/issues/473)) ([ecd88d4](https://www.github.com/googleapis/google-auth-library-python/commit/ecd88d4f0efc5c619ebd3e3fa7e2472f11c63452)) +* Show the transport exception that happened for GCE Metadata ([#474](https://www.github.com/googleapis/google-auth-library-python/issues/474)) ([23919bb](https://www.github.com/googleapis/google-auth-library-python/commit/23919bb60e5f9d9b73644e9a2e127d4d1dd68e8c)) +* **packaging:** add support for Python 3.8 ([#569](https://www.github.com/googleapis/google-auth-library-python/issues/569)) ([1aad54a](https://www.github.com/googleapis/google-auth-library-python/commit/1aad54af6b1d5da73d7471cdbfaf0d0b37c5fde6)), closes [#568](https://www.github.com/googleapis/google-auth-library-python/issues/568) + +## [1.19.2](https://www.github.com/googleapis/google-auth-library-python/compare/v1.19.1...v1.19.2) (2020-07-17) + + +### Bug fixes + +* Revert "fix: migrate signBlob to iamcredentials.googleapis.com" ([#563](https://www.github.com/googleapis/google-auth-library-python/issues/563)) ([a48b5b](https://www.github.com/googleapis/google-auth-library-python/commit/a48b5b9135b30ff06f1fe18dd9dbe92ffcf3a272)) + +## [1.19.1](https://www.github.com/googleapis/google-auth-library-python/compare/v1.19.0...v1.19.1) (2020-07-15) + + +### Bug Fixes + +* don't add empty quota project ([#560](https://www.github.com/googleapis/google-auth-library-python/issues/560)) ([ab2be5d](https://www.github.com/googleapis/google-auth-library-python/commit/ab2be5de829e830979514683582c11f98fa943c7)) + +## [1.19.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.18.0...v1.19.0) (2020-07-09) + + +### Features + +* add quota project to base credentials class ([#546](https://www.github.com/googleapis/google-auth-library-python/issues/546)) ([3dda7b2](https://www.github.com/googleapis/google-auth-library-python/commit/3dda7b2ab88aba7941b8b5281b4acbc7db74169b)) +* check 'iss' in `verify_oauth2_token` ([#500](https://www.github.com/googleapis/google-auth-library-python/issues/500)) ([c05b8b5](https://www.github.com/googleapis/google-auth-library-python/commit/c05b8b52e3bbc096cf32e2d4bb5bd45986d3cd04)) + + +### Bug Fixes + +* migrate signBlob to iamcredentials.googleapis.com ([#553](https://www.github.com/googleapis/google-auth-library-python/issues/553)) ([038ae1b](https://www.github.com/googleapis/google-auth-library-python/commit/038ae1b78dc83e44ad39ef7ba15c607f62232087)) + + +### Documentation + +* remove 3.4 from supported versions list ([#549](https://www.github.com/googleapis/google-auth-library-python/issues/549)) ([8c84d0f](https://www.github.com/googleapis/google-auth-library-python/commit/8c84d0fb36d9eba6b319964ca0a22501efca805b)) + +## [1.18.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.17.2...v1.18.0) (2020-06-18) + + +### Features + +* make ``load_credentials_from_file`` a public method ([#530](https://www.github.com/googleapis/google-auth-library-python/issues/530)) ([15d5fa9](https://www.github.com/googleapis/google-auth-library-python/commit/15d5fa946177581b52a5a9eb3ca285c088f5c45d)) + + +### Bug Fixes + +* no warning if quota_project_id is given ([#537](https://www.github.com/googleapis/google-auth-library-python/issues/537)) ([f30b45a](https://www.github.com/googleapis/google-auth-library-python/commit/f30b45a9b2f824c494724548732c5ce838218c30)) + +## [1.17.2](https://www.github.com/googleapis/google-auth-library-python/compare/v1.17.1...v1.17.2) (2020-06-12) + + +### Bug Fixes + +* **dependencies:** Further restrict RSA versions ([#532](https://www.github.com/googleapis/google-auth-library-python/issues/532)) ([46677a0](https://www.github.com/googleapis/google-auth-library-python/commit/46677a0cb3bde6622be10061bc61daaff7a0aaca)), closes [#528](https://www.github.com/googleapis/google-auth-library-python/issues/528) + +## [1.17.1](https://www.github.com/googleapis/google-auth-library-python/compare/v1.17.0...v1.17.1) (2020-06-11) + + +### Bug Fixes + +* narrow acceptable RSA versions to maintain Python 2 compatability ([#528](https://www.github.com/googleapis/google-auth-library-python/issues/528)) ([9434868](https://www.github.com/googleapis/google-auth-library-python/commit/9434868a6789464549af1d4562f62d8a899b6809)) + +## [1.17.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.16.1...v1.17.0) (2020-06-10) + + +### Features + +* add quota_project_id to service accounts; add with_quota_project methods ([#519](https://www.github.com/googleapis/google-auth-library-python/issues/519)) ([b12488c](https://www.github.com/googleapis/google-auth-library-python/commit/b12488cf552888299425c8009ea075511627cf08)) + +## [1.16.1](https://www.github.com/googleapis/google-auth-library-python/compare/v1.16.0...v1.16.1) (2020-06-04) + + +### Bug Fixes + +* fix impersonated cred exception doc ([#521](https://www.github.com/googleapis/google-auth-library-python/issues/521)) ([9d5a9a9](https://www.github.com/googleapis/google-auth-library-python/commit/9d5a9a9884fecbd698a602d2a9fd9bec6b987de7)) +* replace environment variable GCE_METADATA_ROOT with GCE_METADATA_HOST ([#433](https://www.github.com/googleapis/google-auth-library-python/issues/433)) ([8ffb4d3](https://www.github.com/googleapis/google-auth-library-python/commit/8ffb4d3e832607869026444e5a071c5f3e225fd2)), closes [#339](https://www.github.com/googleapis/google-auth-library-python/issues/339) + +## [1.16.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.15.0...v1.16.0) (2020-05-28) + + +### Features + +* add helper func to for default encrypted cert ([#514](https://www.github.com/googleapis/google-auth-library-python/issues/514)) ([f282aa4](https://www.github.com/googleapis/google-auth-library-python/commit/f282aa4acc73d5b56aa7d4bb745d286c3cf1fc39)) + + +### Bug Fixes + +* fix impersonated cred for gcloud ([#516](https://www.github.com/googleapis/google-auth-library-python/issues/516)) ([eb7be3f](https://www.github.com/googleapis/google-auth-library-python/commit/eb7be3fa98ace42b3e949a8af90bbb978ae7e455)) + +## [1.15.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.14.3...v1.15.0) (2020-05-15) + + +### Features + +* encrypted mtls private key support ([#496](https://www.github.com/googleapis/google-auth-library-python/issues/496)) ([9dc9e9f](https://www.github.com/googleapis/google-auth-library-python/commit/9dc9e9f4ca65780b4d7f24e2c36021d2300b4006)) + + +### Bug Fixes + +* signBytes for impersonated credentials ([#506](https://www.github.com/googleapis/google-auth-library-python/issues/506)) ([ca8d98a](https://www.github.com/googleapis/google-auth-library-python/commit/ca8d98ab2e5277e53ab8df78beb1e75cdf5321e3)), closes [#338](https://www.github.com/googleapis/google-auth-library-python/issues/338) + +## [1.14.3](https://www.github.com/googleapis/google-auth-library-python/compare/v1.14.2...v1.14.3) (2020-05-11) + + +### Bug Fixes + +* catch exceptions.RefreshError ([#508](https://www.github.com/googleapis/google-auth-library-python/issues/508)) ([3d672e9](https://www.github.com/googleapis/google-auth-library-python/commit/3d672e9cddd9e8c4946290ab9f90ca9009b8be69)) + +## [1.14.2](https://www.github.com/googleapis/google-auth-library-python/compare/v1.14.1...v1.14.2) (2020-05-07) + + +### Bug Fixes + +* support string type response.data ([#504](https://www.github.com/googleapis/google-auth-library-python/issues/504)) ([9b7228e](https://www.github.com/googleapis/google-auth-library-python/commit/9b7228ec849e311bcb4007ad3e23cf2f1e54a721)) + +## [1.14.1](https://www.github.com/googleapis/google-auth-library-python/compare/v1.14.0...v1.14.1) (2020-04-21) + + +### Bug Fixes + +* support es256 raw format signature ([#490](https://www.github.com/googleapis/google-auth-library-python/issues/490)) ([cf2c0a9](https://www.github.com/googleapis/google-auth-library-python/commit/cf2c0a90701ce42f47df71281ae9cdf212c28e0e)) + +## [1.14.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.13.1...v1.14.0) (2020-04-13) + + +### Features + +* add default client cert source util ([#486](https://www.github.com/googleapis/google-auth-library-python/issues/486)) ([ed41b49](https://www.github.com/googleapis/google-auth-library-python/commit/ed41b49e9d7ba7402b27107b7aa47eed06ac6c55)) + +## [1.13.1](https://www.github.com/googleapis/google-auth-library-python/compare/v1.13.0...v1.13.1) (2020-04-01) + + +### Bug Fixes + +* invalid expiry type ([#481](https://www.github.com/googleapis/google-auth-library-python/issues/481)) ([7ae9a28](https://www.github.com/googleapis/google-auth-library-python/commit/7ae9a284dae16d274bfd4d876414f08efd6c3bff)) + +## [1.13.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.12.0...v1.13.0) (2020-04-01) + + +### Features + +* add access token credentials ([#476](https://www.github.com/googleapis/google-auth-library-python/issues/476)) ([772dac6](https://www.github.com/googleapis/google-auth-library-python/commit/772dac6a6512230d32cb0dfae65a1a6aa9015049)) +* add fetch_id_token to support id_token adc ([#469](https://www.github.com/googleapis/google-auth-library-python/issues/469)) ([506c565](https://www.github.com/googleapis/google-auth-library-python/commit/506c565a8c3c23a78fd0f17991bc6deb6f2528a9)) +* consolidate mTLS channel errors ([#480](https://www.github.com/googleapis/google-auth-library-python/issues/480)) ([e83d446](https://www.github.com/googleapis/google-auth-library-python/commit/e83d4462f5c50f8424d9e54be32c29390115a9ed)) +* Implement ES256 for JWT verification ([#340](https://www.github.com/googleapis/google-auth-library-python/issues/340)) ([e290a3d](https://www.github.com/googleapis/google-auth-library-python/commit/e290a3dbecc4767dd25ee14574951cdb6c2157cb)) + +## [1.12.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.11.3...v1.12.0) (2020-03-25) + + +### Features + +* add mTLS ADC support for HTTP ([#457](https://www.github.com/googleapis/google-auth-library-python/issues/457)) ([bb9215a](https://www.github.com/googleapis/google-auth-library-python/commit/bb9215ad6dee6c1dc7f255a2e4ed7011b85bd6cf)) +* add SslCredentials class for mTLS ADC ([#448](https://www.github.com/googleapis/google-auth-library-python/issues/448)) ([dafb41f](https://www.github.com/googleapis/google-auth-library-python/commit/dafb41fae3f513ea9a4f93404f6148bee7dda202)) +* fetch id token from GCE metadata server ([#462](https://www.github.com/googleapis/google-auth-library-python/issues/462)) ([97e7700](https://www.github.com/googleapis/google-auth-library-python/commit/97e7700da031bfd80b63b1a3d2abc29c500936ef)) + + +### Bug Fixes + +* don't use threads for gRPC AuthMetadataPlugin ([#467](https://www.github.com/googleapis/google-auth-library-python/issues/467)) ([ee373f8](https://www.github.com/googleapis/google-auth-library-python/commit/ee373f88b512a38e791a1c085452c6c6da501eb6)) +* make ThreadPoolExecutor a class var ([#461](https://www.github.com/googleapis/google-auth-library-python/issues/461)) ([b526473](https://www.github.com/googleapis/google-auth-library-python/commit/b5264730603947295cc97ecff2f6aef84aa3d6e9)) + +## [1.11.3](https://www.github.com/googleapis/google-auth-library-python/compare/v1.11.2...v1.11.3) (2020-03-13) + + +### Bug Fixes + +* fix the scopes so test can pass for a local run ([#450](https://www.github.com/googleapis/google-auth-library-python/issues/450)) ([b2dd77f](https://www.github.com/googleapis/google-auth-library-python/commit/b2dd77fe4a538e1d165fc9d859c9a299f6832cda)) +* only add IAM scope to credentials that can change scopes ([#451](https://www.github.com/googleapis/google-auth-library-python/issues/451)) ([82e224b](https://www.github.com/googleapis/google-auth-library-python/commit/82e224b0854950a5607cd028edbcbcdc3e9e6505)) + +## [1.11.2](https://www.github.com/googleapis/google-auth-library-python/compare/v1.11.1...v1.11.2) (2020-02-14) + + +### Reverts + +* Revert "fix: update `_GOOGLE_OAUTH2_CERTS_URL` (#365)" (#444) ([901c259](https://www.github.com/googleapis/google-auth-library-python/commit/901c259b1764f5a305a542cbae14d926ba7a57db)), closes [#365](https://www.github.com/googleapis/google-auth-library-python/issues/365) [#444](https://www.github.com/googleapis/google-auth-library-python/issues/444) + +## [1.11.1](https://www.github.com/googleapis/google-auth-library-python/compare/v1.11.0...v1.11.1) (2020-02-13) + + +### Bug Fixes + +* compute engine id token credentials "with_target_audience" method ([#438](https://www.github.com/googleapis/google-auth-library-python/issues/438)) ([bc0ec93](https://www.github.com/googleapis/google-auth-library-python/commit/bc0ec93dc66fdcaa6a82222386623fa44f24ddfe)) +* update `_GOOGLE_OAUTH2_CERTS_URL` ([#365](https://www.github.com/googleapis/google-auth-library-python/issues/365)) ([054db75](https://www.github.com/googleapis/google-auth-library-python/commit/054db75734756b0e82e7984ca07fa80025edc908)) + +## [1.11.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.10.2...v1.11.0) (2020-01-23) + + +### Features + +* add non-None default timeout to AuthorizedSession.request() ([#435](https://www.github.com/googleapis/google-auth-library-python/issues/435)) ([d274a3a](https://www.github.com/googleapis/google-auth-library-python/commit/d274a3a2b3f913bc2cab4ca51f9c7fdef94b8f31)), closes [#434](https://www.github.com/googleapis/google-auth-library-python/issues/434) [googleapis/google-cloud-python#10182](https://www.github.com/googleapis/google-cloud-python/issues/10182) +* distinguish transport and execution time timeouts ([#424](https://www.github.com/googleapis/google-auth-library-python/issues/424)) ([52a733d](https://www.github.com/googleapis/google-auth-library-python/commit/52a733d604528fa86d05321bb74241a43aea4211)), closes [#423](https://github.com/googleapis/google-auth-library-python/issues/423) + +## [1.10.2](https://www.github.com/googleapis/google-auth-library-python/compare/v1.10.1...v1.10.2) (2020-01-18) + + +### Bug Fixes + +* make collections import compatible across Python versions ([#419](https://www.github.com/googleapis/google-auth-library-python/issues/419)) ([c5a3395](https://www.github.com/googleapis/google-auth-library-python/commit/c5a3395b8781e14c4566cf0e476b234d6a1c1224)), closes [#418](https://www.github.com/googleapis/google-auth-library-python/issues/418) + +## [1.10.1](https://www.github.com/googleapis/google-auth-library-python/compare/v1.10.0...v1.10.1) (2020-01-10) + + +### Bug Fixes + +* **google.auth.compute_engine.metadata:** add retry to google.auth.compute_engine._metadata.get() ([#398](https://www.github.com/googleapis/google-auth-library-python/issues/398)) ([af29c1a](https://www.github.com/googleapis/google-auth-library-python/commit/af29c1a9fd9282b38867961e4053f74f018a3815)), closes [#211](https://www.github.com/googleapis/google-auth-library-python/issues/211) [#323](https://www.github.com/googleapis/google-auth-library-python/issues/323) [#323](https://www.github.com/googleapis/google-auth-library-python/issues/323) [#211](https://www.github.com/googleapis/google-auth-library-python/issues/211) +* always pass body of type bytes to `google.auth.transport.Request` ([#421](https://www.github.com/googleapis/google-auth-library-python/issues/421)) ([a57a770](https://www.github.com/googleapis/google-auth-library-python/commit/a57a7708cfea635b5030f8c7ba10c967715f9a87)), closes [#318](https://www.github.com/googleapis/google-auth-library-python/issues/318) + +## [1.10.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.9.0...v1.10.0) (2019-12-18) + + +### Features + +* send quota project id in x-goog-user-project for OAuth2 credentials ([#412](https://www.github.com/googleapis/google-auth-library-python/issues/412)) ([32d71a5](https://www.github.com/googleapis/google-auth-library-python/commit/32d71a5858435af0818a705b754404882bb7bb9e)), closes [#400](https://www.github.com/googleapis/google-auth-library-python/issues/400) + +## [1.9.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.8.2...v1.9.0) (2019-12-12) + + +### Features + +* add timeout parameter to `AuthorizedSession.request()` ([#406](https://www.github.com/googleapis/google-auth-library-python/issues/406)) ([d86d7b8](https://www.github.com/googleapis/google-auth-library-python/commit/d86d7b8c43df152765c7fc59a54015361b46dcde)) + +## [1.8.2](https://www.github.com/googleapis/google-auth-library-python/compare/v1.8.1...v1.8.2) (2019-12-11) + + +### Bug Fixes + +* revert "feat: send quota project id in x-goog-user-project header for OAuth2 credentials ([#400](https://www.github.com/googleapis/google-auth-library-python/issues/400))" ([#407](https://www.github.com/googleapis/google-auth-library-python/issues/407)) ([25ea942](https://www.github.com/googleapis/google-auth-library-python/commit/25ea942cef4378ff22adf235dd1baf1ca0d595f8)) + +## [1.8.1](https://www.github.com/googleapis/google-auth-library-python/compare/v1.8.0...v1.8.1) (2019-12-09) + + +### Bug Fixes + +* revert "feat: add timeout to AuthorizedSession.request() ([#397](https://www.github.com/googleapis/google-auth-library-python/issues/397))" ([#401](https://www.github.com/googleapis/google-auth-library-python/issues/401)) ([451ecbd](https://www.github.com/googleapis/google-auth-library-python/commit/451ecbd48a910348bbf7a7b38162a044fad6e6e1)) + +## [1.8.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.7.2...v1.8.0) (2019-12-09) + + +### Features + +* add `to_json` method to google.oauth2.credentials.Credentials ([#367](https://www.github.com/googleapis/google-auth-library-python/issues/367)) ([bfb1f8c](https://www.github.com/googleapis/google-auth-library-python/commit/bfb1f8cc8a706ce5ca2a14886c920ca2220ec349)) +* add timeout to AuthorizedSession.request() ([#397](https://www.github.com/googleapis/google-auth-library-python/issues/397)) ([381dd40](https://www.github.com/googleapis/google-auth-library-python/commit/381dd400911d29926ffbf04e0f2ba53ef7bb997e)) +* send quota project id in x-goog-user-project header for OAuth2 credentials ([#400](https://www.github.com/googleapis/google-auth-library-python/issues/400)) ([ab3dc1e](https://www.github.com/googleapis/google-auth-library-python/commit/ab3dc1e26f5240ea3456de364c7c5cb8f40f9583)) + +## [1.7.2](https://www.github.com/googleapis/google-auth-library-python/compare/v1.7.1...v1.7.2) (2019-12-02) + + +### Bug Fixes + +* in token endpoint request, do not decode the response data if it is not encoded ([#393](https://www.github.com/googleapis/google-auth-library-python/issues/393)) ([3b5d3e2](https://www.github.com/googleapis/google-auth-library-python/commit/3b5d3e2192ce0cdc97854a1d70d5e382e454275c)) +* make gRPC auth plugin non-blocking + add default timeout value for requests transport ([#390](https://www.github.com/googleapis/google-auth-library-python/issues/390)) ([0c33e9c](https://www.github.com/googleapis/google-auth-library-python/commit/0c33e9c0fe4f87fa46c8f1a5afe725a467ac5fcc)), closes [#351](https://www.github.com/googleapis/google-auth-library-python/issues/351) + +## [1.7.1](https://www.github.com/googleapis/google-auth-library-python/compare/v1.7.0...v1.7.1) (2019-11-13) + + +### Bug Fixes + +* change 'internal_failure' condition to also use `error' field ([#387](https://www.github.com/googleapis/google-auth-library-python/issues/387)) ([46bb58e](https://www.github.com/googleapis/google-auth-library-python/commit/46bb58e694716908a5ed00f05dbb794cdec667dd)) + +## 1.7.0 + +10-30-2019 17:11 PDT + + +### Implementation Changes +- Add retry loop for fetching authentication token if any 'Internal Failure' occurs ([#368](https://github.com/googleapis/google-auth-library-python/pull/368)) +- Use cls parameter instead of class ([#341](https://github.com/googleapis/google-auth-library-python/pull/341)) + +### New Features +- Add support for `impersonated_credentials.Sign`, `IDToken` ([#348](https://github.com/googleapis/google-auth-library-python/pull/348)) +- Add downscoping to OAuth2 credentials ([#309](https://github.com/googleapis/google-auth-library-python/pull/309)) + +### Dependencies +- Update dependency cachetools to v3 ([#357](https://github.com/googleapis/google-auth-library-python/pull/357)) +- Update dependency rsa to v4 ([#358](https://github.com/googleapis/google-auth-library-python/pull/358)) +- Set an upper bound on dependencies version ([#352](https://github.com/googleapis/google-auth-library-python/pull/352)) +- Require a minimum version of setuptools ([#322](https://github.com/googleapis/google-auth-library-python/pull/322)) + +### Documentation +- Add busunkim96 as maintainer ([#373](https://github.com/googleapis/google-auth-library-python/pull/373)) +- Update user-guide.rst ([#337](https://github.com/googleapis/google-auth-library-python/pull/337)) +- Fix typo in jwt docs ([#332](https://github.com/googleapis/google-auth-library-python/pull/332)) +- Clarify which SA has Token Creator role ([#330](https://github.com/googleapis/google-auth-library-python/pull/330)) + +### Internal / Testing Changes +- Change 'name' to distribution name ([#379](https://github.com/googleapis/google-auth-library-python/pull/379)) +- Fix system tests, move to Kokoro ([#372](https://github.com/googleapis/google-auth-library-python/pull/372)) +- Blacken ([#375](https://github.com/googleapis/google-auth-library-python/pull/375)) +- Rename nox.py -> noxfile.py ([#369](https://github.com/googleapis/google-auth-library-python/pull/369)) +- Add initial renovate config ([#356](https://github.com/googleapis/google-auth-library-python/pull/356)) +- Use new pytest api to keep building with pytest 5 ([#353](https://github.com/googleapis/google-auth-library-python/pull/353)) + + +## 1.6.3 + +02-15-2019 9:31 PST + +### Implementation Changes + +- follow rfc 7515 : strip padding from JWS segments ([#324](https://github.com/googleapis/google-auth-library-python/pull/324)) +- Add retry to `_metadata.ping()` ([#323](https://github.com/googleapis/google-auth-library-python/pull/323)) + +## 1.6.2 + +12-17-2018 10:51 PST + +### Documentation + +- Announce deprecation of Python 2.7 ([#311](https://github.com/googleapis/google-auth-library-python/pull/311)) +- Link all the PRs in CHANGELOG ([#307](https://github.com/googleapis/google-auth-library-python/pull/307)) + +## 1.6.1 + +11-12-2018 10:10 PST + +### Implementation Changes + +- Automatically refresh impersonated credentials ([#304](https://github.com/googleapis/google-auth-library-python/pull/304)) + +## 1.6.0 + +11-09-2018 11:07 PST + +### New Features + +- Add `google.auth.impersonated_credentials` ([#299](https://github.com/googleapis/google-auth-library-python/pull/299)) + +### Documentation + +- Update link to documentation for default credentials ([#296](https://github.com/googleapis/google-auth-library-python/pull/296)) +- Update github issue templates ([#300](https://github.com/googleapis/google-auth-library-python/pull/300)) +- Remove punctuation which becomes part of the url ([#284](https://github.com/googleapis/google-auth-library-python/pull/284)) + +### Internal / Testing Changes + +- Update trampoline.sh ([302](https://github.com/googleapis/google-auth-library-python/pull/302)) +- Enable static type checking with pytype ([#298](https://github.com/googleapis/google-auth-library-python/pull/298)) +- Make classifiers in setup.py an array. ([#280](https://github.com/googleapis/google-auth-library-python/pull/280)) + + +## 1.5.1 + +- Fix check for error text on Python 3.7. ([#278](https://github.com/googleapis/google-auth-library-python/pull/#278)) +- Use new Auth URIs. ([#281](https://github.com/googleapis/google-auth-library-python/pull/#281)) +- Add code-of-conduct document. ([#270](https://github.com/googleapis/google-auth-library-python/pull/#270)) +- Fix some typos in test_urllib3.py ([#268](https://github.com/googleapis/google-auth-library-python/pull/#268)) + +## 1.5.0 + +- Warn when using user credentials from the Cloud SDK ([#266](https://github.com/googleapis/google-auth-library-python/pull/266)) +- Add compute engine-based IDTokenCredentials ([#236](https://github.com/googleapis/google-auth-library-python/pull/236)) +- Corrected some typos ([#265](https://github.com/googleapis/google-auth-library-python/pull/265)) + +## 1.4.2 + +- Raise a helpful exception when trying to refresh credentials without a refresh token. ([#262](https://github.com/googleapis/google-auth-library-python/pull/262)) +- Fix links to README and CONTRIBUTING in docs/index.rst. ([#260](https://github.com/googleapis/google-auth-library-python/pull/260)) +- Fix a typo in credentials.py. ([#256](https://github.com/googleapis/google-auth-library-python/pull/256)) +- Use pytest instead of py.test per upstream recommendation, #dropthedot. ([#255](https://github.com/googleapis/google-auth-library-python/pull/255)) +- Fix typo on exemple of jwt usage ([#245](https://github.com/googleapis/google-auth-library-python/pull/245)) + +## 1.4.1 + +- Added a check for the cryptography version before attempting to use it. ([#243](https://github.com/googleapis/google-auth-library-python/pull/243)) + +## 1.4.0 + +- Added `cryptography`-based RSA signer and verifier. ([#185](https://github.com/googleapis/google-auth-library-python/pull/185)) +- Added `google.oauth2.service_account.IDTokenCredentials`. ([#234](https://github.com/googleapis/google-auth-library-python/pull/234)) +- Improved documentation around ID Tokens ([#224](https://github.com/googleapis/google-auth-library-python/pull/224)) + +## 1.3.0 + +- Added ``google.oauth2.credentials.Credentials.from_authorized_user_file`` ([#226](https://github.com/googleapis/google-auth-library-python/pull/#226)) +- Dropped direct pyasn1 dependency in favor of letting ``pyasn1-modules`` specify the right version. ([#230](https://github.com/googleapis/google-auth-library-python/pull/#230)) +- ``default()`` now checks for the project ID environment var before warning about missing project ID. ([#227](https://github.com/googleapis/google-auth-library-python/pull/#227)) +- Fixed the docstrings for ``has_scopes()`` and ``with_scopes()``. ([#228](https://github.com/googleapis/google-auth-library-python/pull/#228)) +- Fixed example in docstring for ``ReadOnlyScoped``. ([#219](https://github.com/googleapis/google-auth-library-python/pull/#219)) +- Made ``transport.requests`` use timeouts and retries to improve reliability. ([#220](https://github.com/googleapis/google-auth-library-python/pull/#220)) + +## 1.2.1 + +- Excluded compiled Python files in source distributions. ([#215](https://github.com/googleapis/google-auth-library-python/pull/#215)) +- Updated docs for creating RSASigner from string. ([#213](https://github.com/googleapis/google-auth-library-python/pull/#213)) +- Use ``six.raise_from`` wherever possible. ([#212](https://github.com/googleapis/google-auth-library-python/pull/#212)) +- Fixed a typo in a comment ``seconds`` not ``sections``. ([#210](https://github.com/googleapis/google-auth-library-python/pull/#210)) + +## 1.2.0 + +- Added ``google.auth.credentials.AnonymousCredentials``. ([#206](https://github.com/googleapis/google-auth-library-python/pull/#206)) +- Updated the documentation to link to the Google Cloud Platform Python setup guide ([#204](https://github.com/googleapis/google-auth-library-python/pull/#204)) + +## 1.1.1 + +- ``google.oauth.credentials.Credentials`` now correctly inherits from ``ReadOnlyScoped`` instead of ``Scoped``. ([#200](https://github.com/googleapis/google-auth-library-python/pull/#200)) + +## 1.1.0 + +- Added ``service_account.Credentials.project_id``. ([#187](https://github.com/googleapis/google-auth-library-python/pull/#187)) +- Move read-only methods of ``credentials.Scoped`` into new interface ``credentials.ReadOnlyScoped``. ([#195](https://github.com/googleapis/google-auth-library-python/pull/#195), [#196](https://github.com/googleapis/google-auth-library-python/pull/#196)) +- Make ``compute_engine.Credentials`` derive from ``ReadOnlyScoped`` instead of ``Scoped``. ([#195](https://github.com/googleapis/google-auth-library-python/pull/#195)) +- Fix App Engine's expiration calculation ([#197](https://github.com/googleapis/google-auth-library-python/pull/#197)) +- Split ``crypt`` module into a package to allow alternative implementations. ([#189](https://github.com/googleapis/google-auth-library-python/pull/#189)) +- Add error message to handle case of empty string or missing file for `GOOGLE_APPLICATION_CREDENTIALS` ([#188](https://github.com/googleapis/google-auth-library-python/pull/#188)) + +## 1.0.2 + +- Fixed a bug where the Cloud SDK executable could not be found on Windows, leading to project ID detection failing. ([#179](https://github.com/googleapis/google-auth-library-python/pull/#179)) +- Fixed a bug where the timeout argument wasn't being passed through the httplib transport correctly. ([#175](https://github.com/googleapis/google-auth-library-python/pull/#175)) +- Added documentation for using the library on Google App Engine standard. ([#172](https://github.com/googleapis/google-auth-library-python/pull/#172)) +- Testing style updates. ([#168](https://github.com/googleapis/google-auth-library-python/pull/#168)) +- Added documentation around the oauth2client deprecation. ([#165](https://github.com/googleapis/google-auth-library-python/pull/#165)) +- Fixed a few lint issues caught by newer versions of pylint. ([#166](https://github.com/googleapis/google-auth-library-python/pull/#166)) + +## 1.0.1 + +- Fixed a bug in the clock skew accommodation logic where expired credentials could be used for up to 5 minutes. ([#158](https://github.com/googleapis/google-auth-library-python/pull/158)) + +## 1.0.0 + +Milestone release for v1.0.0. +No significant changes since v0.10.0 + +## 0.10.0 + +- Added ``jwt.OnDemandCredentials``. ([#142](https://github.com/googleapis/google-auth-library-python/pull/142)) +- Added new public property ``id_token`` to ``oauth2.credentials.Credentials``. ([#150](https://github.com/googleapis/google-auth-library-python/pull/150)) +- Added the ability to set the address used to communicate with the Compute Engine metadata server via the ``GCE_METADATA_ROOT`` and ``GCE_METADATA_IP`` environment variables. ([#148](https://github.com/googleapis/google-auth-library-python/pull/148)) +- Changed the way cloud project IDs are ascertained from the Google Cloud SDK. ([#147](https://github.com/googleapis/google-auth-library-python/pull/147)) +- Modified expiration logic to add a 5 minute clock skew accommodation. ([#145](https://github.com/googleapis/google-auth-library-python/pull/145)) + +## 0.9.0 + +- Added ``service_account.Credentials.with_claims``. ([#140](https://github.com/googleapis/google-auth-library-python/pull/140)) +- Moved ``google.auth.oauthlib`` and ``google.auth.flow`` to a new separate package ``google_auth_oauthlib``. ([#137](https://github.com/googleapis/google-auth-library-python/pull/137), [#139](https://github.com/googleapis/google-auth-library-python/pull/139), [#135](https://github.com/googleapis/google-auth-library-python/pull/135), [#126](https://github.com/googleapis/google-auth-library-python/pull/126)) +- Added ``InstalledAppFlow`` to ``google_auth_oauthlib``. ([#128](https://github.com/googleapis/google-auth-library-python/pull/128)) +- Fixed some packaging and documentation issues. ([#131](https://github.com/googleapis/google-auth-library-python/pull/131)) +- Added a helpful error message when importing optional dependencies. ([#125](https://github.com/googleapis/google-auth-library-python/pull/125)) +- Made all properties required to reconstruct ``google.oauth2.credentials.Credentials`` public. ([#124](https://github.com/googleapis/google-auth-library-python/pull/124)) +- Added official Python 3.6 support. ([#102](https://github.com/googleapis/google-auth-library-python/pull/102)) +- Added ``jwt.Credentials.from_signing_credentials`` and removed ``service_account.Credentials.to_jwt_credentials``. ([#120](https://github.com/googleapis/google-auth-library-python/pull/120)) + +## 0.8.0 + +- Removed one-time token behavior from ``jwt.Credentials``, audience claim is now required and fixed. ([#117](https://github.com/googleapis/google-auth-library-python/pull/117)) +- ``crypt.Signer`` and ``crypt.Verifier`` are now abstract base classes. The concrete implementations have been renamed to ``crypt.RSASigner`` and ``crypt.RSAVerifier``. ``app_engine.Signer`` and ``iam.Signer`` now inherit from ``crypt.Signer``. ([#115](https://github.com/googleapis/google-auth-library-python/pull/115)) +- ``transport.grpc`` now correctly calls ``Credentials.before_request``. ([#116](https://github.com/googleapis/google-auth-library-python/pull/116)) + +## 0.7.0 + +- Added ``google.auth.iam.Signer``. ([#108](https://github.com/googleapis/google-auth-library-python/pull/108)) +- Fixed issue where ``google.auth.app_engine.Signer`` erroneously returns a tuple from ``sign()``. ([#109](https://github.com/googleapis/google-auth-library-python/pull/109)) +- Added public property ``google.auth.credentials.Signing.signer``. ([#110](https://github.com/googleapis/google-auth-library-python/pull/110)) + +## 0.6.0 + +- Added experimental integration with ``requests-oauthlib`` in ``google.oauth2.oauthlib`` and ``google.oauth2.flow``. ([#100](https://github.com/googleapis/google-auth-library-python/pull/100), [#105](https://github.com/googleapis/google-auth-library-python/pull/105), [#106](https://github.com/googleapis/google-auth-library-python/pull/106)) +- Fixed typo in ``google_auth_httplib2``'s README. ([#105](https://github.com/googleapis/google-auth-library-python/pull/105)) + +## 0.5.0 + +- Added ``app_engine.Signer``. ([#97](https://github.com/googleapis/google-auth-library-python/pull/97)) +- Added ``crypt.Signer.from_service_account_file``. ([#95](https://github.com/googleapis/google-auth-library-python/pull/95)) +- Fixed error handling in the oauth2 client. ([#96](https://github.com/googleapis/google-auth-library-python/pull/96)) +- Fixed the App Engine system tests. + +## 0.4.0 + +- ``transports.grpc.secure_authorized_channel`` now passes ``kwargs`` to ``grpc.secure_channel``. ([#90](https://github.com/googleapis/google-auth-library-python/pull/90)) +- Added new property ``credentials.Singing.signer_email`` which can be used to identify the signer of a message. ([#89](https://github.com/googleapis/google-auth-library-python/pull/89)) +- (google_auth_httplib2) Added a proxy to ``httplib2.Http.connections``. + +## 0.3.2 + +- Fixed an issue where an ``ImportError`` would occur if ``google.oauth2`` was imported before ``google.auth``. ([#88](https://github.com/googleapis/google-auth-library-python/pull/88)) + +## 0.3.1 + +- Fixed a bug where non-padded base64 encoded strings were not accepted. ([#87](https://github.com/googleapis/google-auth-library-python/pull/87)) +- Fixed a bug where ID token verification did not correctly call the HTTP request function. ([#87](https://github.com/googleapis/google-auth-library-python/pull/87)) + +## 0.3.0 + +- Added Google ID token verification helpers. ([#82](https://github.com/googleapis/google-auth-library-python/pull/82)) +- Swapped the ``target`` and ``request`` argument order for ``grpc.secure_authorized_channel``. ([#81](https://github.com/googleapis/google-auth-library-python/pull/81)) +- Added a user's guide. ([#79](https://github.com/googleapis/google-auth-library-python/pull/79)) +- Made ``service_account_email`` a public property on several credential classes. ([#76](https://github.com/googleapis/google-auth-library-python/pull/76)) +- Added a ``scope`` argument to ``google.auth.default``. ([#75](https://github.com/googleapis/google-auth-library-python/pull/75)) +- Added support for the ``GCLOUD_PROJECT`` environment variable. ([#73](https://github.com/googleapis/google-auth-library-python/pull/73)) + +## 0.2.0 + +- Added gRPC support. ([#67](https://github.com/googleapis/google-auth-library-python/pull/67)) +- Added Requests support. ([#66](https://github.com/googleapis/google-auth-library-python/pull/66)) +- Added ``google.auth.credentials.with_scopes_if_required`` helper. ([#65](https://github.com/googleapis/google-auth-library-python/pull/65)) +- Added private helper for oauth2client migration. ([#70](https://github.com/googleapis/google-auth-library-python/pull/70)) + +## 0.1.0 + +First release with core functionality available. This version is ready for +initial usage and testing. + +- Added ``google.auth.credentials``, public interfaces for Credential types. ([#8](https://github.com/googleapis/google-auth-library-python/pull/8)) +- Added ``google.oauth2.credentials``, credentials that use OAuth 2.0 access and refresh tokens ([#24](https://github.com/googleapis/google-auth-library-python/pull/24)) +- Added ``google.oauth2.service_account``, credentials that use Service Account private keys to obtain OAuth 2.0 access tokens. ([#25](https://github.com/googleapis/google-auth-library-python/pull/25)) +- Added ``google.auth.compute_engine``, credentials that use the Compute Engine metadata service to obtain OAuth 2.0 access tokens. ([#22](https://github.com/googleapis/google-auth-library-python/pull/22)) +- Added ``google.auth.jwt.Credentials``, credentials that use a JWT as a bearer token. +- Added ``google.auth.app_engine``, credentials that use the Google App Engine App Identity service to obtain OAuth 2.0 access tokens. ([#46](https://github.com/googleapis/google-auth-library-python/pull/46)) +- Added ``google.auth.default()``, an implementation of Google Application Default Credentials that supports automatic Project ID detection. ([#32](https://github.com/googleapis/google-auth-library-python/pull/32)) +- Added system tests for all credential types. ([#51](https://github.com/googleapis/google-auth-library-python/pull/51), [#54](https://github.com/googleapis/google-auth-library-python/pull/54), [#56](https://github.com/googleapis/google-auth-library-python/pull/56), [#58](https://github.com/googleapis/google-auth-library-python/pull/58), [#59](https://github.com/googleapis/google-auth-library-python/pull/59), [#60](https://github.com/googleapis/google-auth-library-python/pull/60), [#61](https://github.com/googleapis/google-auth-library-python/pull/61), [#62](https://github.com/googleapis/google-auth-library-python/pull/62)) +- Added ``google.auth.transports.urllib3.AuthorizedHttp``, an HTTP client that includes authentication provided by credentials. ([#19](https://github.com/googleapis/google-auth-library-python/pull/19)) +- Documentation style and formatting updates. + +## 0.0.1 + +Initial release with foundational functionality for cryptography and JWTs. + +- ``google.auth.crypt`` for creating and verifying cryptographic signatures. +- ``google.auth.jwt`` for creating (encoding) and verifying (decoding) JSON Web tokens. diff --git a/packages/google-auth/CONTRIBUTING.rst b/packages/google-auth/CONTRIBUTING.rst new file mode 100644 index 000000000000..b19282f3255d --- /dev/null +++ b/packages/google-auth/CONTRIBUTING.rst @@ -0,0 +1,194 @@ +Contributing +============ + +#. **Please sign one of the contributor license agreements below.** +#. Fork the repo, develop and test your code changes, add docs. +#. Make sure that your commit messages clearly describe the changes. +#. Send a pull request. + +Here are some guidelines for hacking on ``google-auth``. + +Making changes +-------------- + +A few notes on making changes to ``google-auth``. + +- If you've added a new feature or modified an existing feature, be sure to + add or update any applicable documentation in docstrings and in the + documentation (in ``docs/``). You can re-generate the reference documentation + using ``nox -s docs``. + +- The change must work fully on the following CPython versions: + 3.7, 3.8, 3.9, 3.10, 3.11, 3.12 and 3.13 across macOS, Linux, and Windows. + +- The codebase *must* have 100% test statement coverage after each commit. + You can test coverage via ``nox -e cover``. + +Testing changes +--------------- + +To test your changes, run unit tests with ``nox``:: + + $ nox -s unit + + +Running system tests +-------------------- + +You can run the system tests with ``nox``:: + + $ nox -f system_tests/noxfile.py + +To run a single session, specify it with ``nox -s``:: + + $ nox -f system_tests/noxfile.py -s service_account + +First, set the environment variable ``GOOGLE_APPLICATION_CREDENTIALS`` to a valid service account. +See `Creating and Managing Service Account Keys`_ for how to obtain a service account. + +Project and Credentials Setup +------------------------------- + +Enable the IAM Service Account Credentials API on the project. + +To run system tests locally, you will need to set up a data directory :: + + $ mkdir system_tests/data + +Your directory should look like this. Follow the instructions below for creating each file. :: + + system_tests/ + data/ + authorized_user.json + impersonated_service_account.json + service_account.json + + +``authorized_user.json`` +~~~~~~~~~~~~~~~~~~~~~~~~ + +Use the `gcloud CLI`_ to get an authorized user file :: + + $ gcloud auth application-default login --scopes=https://www.googleapis.com/auth/userinfo.email,https://www.googleapis.com/auth/cloud-platform,openid + +You will see something like:: + + Credentials saved to file: [/usr/local/home/.config/gcloud/application_default_credentials.json] + +Copy the contents of the file to ``authorized_user.json``. + +Open the IAM page of the Google Cloud Console. Grant the user the `Service Account Token Creator Role`. +This will allow the user to impersonate service accounts on the project. + +.. _gcloud CLI: https://cloud.google.com/sdk/gcloud/ + + +``service_account.json`` +~~~~~~~~~~~~~~~~~~~~~~~~ + +Follow `Creating and Managing Service Account Keys`_ to create a service account. + +Copy the credentials file to ``service_account.json``. + +Grant the account associated with ``service_account.json`` the following roles. + +- App Engine Admin (for App Engine tests) +- Service Account Token Creator (for impersonated credentials and workload identity federation tests) +- Pub/Sub Viewer (for gRPC tests) +- Storage Object Viewer (for impersonated credentials tests) +- DNS Viewer (for workload identity federation tests) +- GCE Storage Bucket Admin (for downscoping tests) + +``impersonated_service_account.json`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Follow `Creating and Managing Service Account Keys`_ to create a service account. + +Copy the credentials file to ``impersonated_service_account.json``. + +.. _Creating and Managing Service Account Keys: https://cloud.google.com/iam/docs/creating-managing-service-account-keys + +``setup_external_accounts`` +~~~~~~~~~~~~~~~~ + +In order to run the workload identity federation tests, you will need to set up +a Workload Identity Pool, as well as attach relevant policy bindings for this +new resource to our service account. To do this, make sure you have IAM Workload +Identity Pool Admin and Security Admin permissions, and then run: + + $ ./scripts/setup_external_accounts.sh + +and then use the output to replace the variables near +the top of system_tests/system_tests_sync/test_external_accounts.py + +App Engine System Tests +~~~~~~~~~~~~~~~~~~~~~~~~ + +To run the App Engine tests, you wil need to deploy a default App Engine service. +If you already have a default service associated with your project, you can skip this step. + +Edit ``app.yaml`` so ``service`` is ``default`` instead of ``google-auth-system-tests``. +From ``system_tests/app_engine_test_app`` run the following commands :: + + $ pip install --target lib -r requirements.txt + $ gcloud app deploy -q app.yaml + +After the app is deployed, change ``service`` in ``app.yaml`` back to ``google-auth-system-tests``. +You can now run the App Engine tests: :: + + $ nox -f system_tests/noxfile.py -s app_engine + +Compute Engine Tests +^^^^^^^^^^^^^^^^^^^^ + +These tests cannot be run locally and will be skipped if they are run outside of Google Compute Engine. + +grpc Tests +^^^^^^^^^^^^ + +These tests use the Pub/Sub API. Grant the service account specified by `GOOGLE_APPLICATION_CREDENTIALS` +permissions to list topics. The service account should have at least `roles/pubsub.viewer`. + +Coding Style +------------ + +This library is PEP8 & Pylint compliant. Our Pylint config is defined at +``pylintrc`` for package code and ``pylintrc.tests`` for test code. Use +``nox`` to check for non-compliant code:: + + $ nox -s lint + +Documentation Coverage and Building HTML Documentation +------------------------------------------------------ + +If you fix a bug, and the bug requires an API or behavior modification, all +documentation in this package which references that API or behavior must be +changed to reflect the bug fix, ideally in the same commit that fixes the bug +or adds the feature. + +To build and review docs use ``nox``:: + + $ nox -s docs + +The HTML version of the docs will be built in ``docs/_build/html`` + +Versioning +---------- + +This library follows `Semantic Versioning`_. + +.. _Semantic Versioning: http://semver.org/ + +It is currently in major version zero (``0.y.z``), which means that anything +may change at any time and the public API should not be considered +stable. + +Contributor License Agreements +------------------------------ + +Before we can accept your pull requests you'll need to sign a Contributor License Agreement (CLA): + +- **If you are an individual writing original source code** and **you own the intellectual property**, then you'll need to sign an `individual CLA `__. +- **If you work for a company that wants to allow you to contribute your work**, then you'll need to sign a `corporate CLA `__. + +You can sign these electronically (just scroll to the bottom). After that, we'll be able to accept your pull requests. diff --git a/packages/google-auth/CONTRIBUTORS.md b/packages/google-auth/CONTRIBUTORS.md new file mode 100644 index 000000000000..501db6328958 --- /dev/null +++ b/packages/google-auth/CONTRIBUTORS.md @@ -0,0 +1,96 @@ +# Contribors to oauth2client / google-auth + +## Maintainers + +* [Jon Wayne Parrott](https://github.com/jonparrott) +* [Danny Hermes](https://github.com/dhermes) +* [Brian Watson](https://github.com/bjwatson) + +Previous maintainers: + +* [Nathaniel Manista](https://github.com/nathanielmanistaatgoogle) +* [Craig Citro](https://github.com/craigcitro) +* [Joe Gregorio](https://github.com/jcgregorio) + +## Contributors + +This list is generated from git commit authors. + +* aalexand +* Aaron +* Adam Chainz +* ade@google.com +* Alexandre Vivien +* Ali Afshar +* Andrzej Pragacz +* api.nickm@gmail.com +* Ben Demaree +* Bill Prin +* Brendan McCollam +* Craig Citro +* Dan Ring +* Daniel Hermes +* Danilo Akamine +* daryl herzmann +* dlorenc +* Dominik Miedziński +* dr. Kertész Csaba-Zoltán +* Dustin Farris +* Eddie Warner +* Edwin Amsler +* elibixby +* Emanuele Pucciarelli +* Eric Koleda +* Frederik Creemers +* Guido van Rossum +* Harsh Vardhan +* Herr Kaste +* INADA Naoki +* JacobMoshenko +* Jay Lee +* Jed Hartman +* Jeff Terrace +* Jeffrey Sorensen +* Jeremi Joslin +* Jin Liu +* Joe Beda +* Joe Gregorio +* Johan Euphrosine +* John Asmuth +* John Vandenberg +* Jon Wayne Parrott +* Jose Alcerreca +* KCs +* Keith Maxwell +* Ken Payson +* Kevin Regan +* lraccomando +* Luar Roji +* Luke Blanshard +* Marc Cohen +* Mark Pellegrini +* Martin Trigaux +* Matt McDonald +* Nathan Naze +* Nathaniel Manista +* Orest Bolohan +* Pat Ferate +* Patrick Costello +* Rafe Kaplan +* rahulpaul@google.com +* RM Saksida +* Robert Kaplow +* Robert Spies +* Sergei Trofimovich +* sgomes@google.com +* Simon Cadman +* soltanmm +* Sébastien de Melo +* takuya sato +* thobrla +* Tom Miller +* Tony Aiuto +* Travis Hobrla +* Veres Lajos +* Vivek Seth +* Éamonn McManus diff --git a/packages/google-auth/LICENSE b/packages/google-auth/LICENSE new file mode 100644 index 000000000000..261eeb9e9f8b --- /dev/null +++ b/packages/google-auth/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/packages/google-auth/MANIFEST.in b/packages/google-auth/MANIFEST.in new file mode 100644 index 000000000000..e72c449313a0 --- /dev/null +++ b/packages/google-auth/MANIFEST.in @@ -0,0 +1,4 @@ +include README.rst LICENSE CHANGELOG.rst +recursive-include tests * +global-exclude *.pyc __pycache__ +global-include *.typed diff --git a/packages/google-auth/README.rst b/packages/google-auth/README.rst new file mode 100644 index 000000000000..5499651cbbf6 --- /dev/null +++ b/packages/google-auth/README.rst @@ -0,0 +1,92 @@ +Google Auth Python Library +========================== + +|pypi| + +This library simplifies using Google's various server-to-server authentication +mechanisms to access Google APIs. + +.. |pypi| image:: https://img.shields.io/pypi/v/google-auth.svg + :target: https://pypi.python.org/pypi/google-auth + +Installing +---------- + +You can install using `pip`_:: + + $ pip install google-auth + +.. _pip: https://pip.pypa.io/en/stable/ + +For more information on setting up your Python development environment, please refer to `Python Development Environment Setup Guide`_ for Google Cloud Platform. + +.. _`Python Development Environment Setup Guide`: https://cloud.google.com/python/docs/setup + +Extras +------ + +google-auth has few extras that you can install. For example:: + + $ pip install google-auth[pyopenssl] + +Note that the extras pyopenssl and enterprise_cert should not be used together because they use conflicting versions of `cryptography`_. + +.. _`cryptography`: https://cryptography.io/en/latest/ + +Supported Python Versions +^^^^^^^^^^^^^^^^^^^^^^^^^ +Python >= 3.7 + +**NOTE**: +Python 3.7 was marked as `unsupported`_ by the python community in June 2023. +We recommend that all developers upgrade to Python 3.8 and newer as soon as +they can. Support for Python 3.7 will be removed from this library after +January 1 2024. Previous releases that support Python 3.7 will continue to be available +for download, but releases after January 1 2024 will only target Python 3.8 and +newer. + +.. _unsupported: https://devguide.python.org/versions/#unsupported-versions + +Unsupported Python Versions +^^^^^^^^^^^^^^^^^^^^^^^^^^^ +- Python == 2.7: The last version of this library with support for Python 2.7 + was `google.auth == 1.34.0`. + +- Python 3.5: The last version of this library with support for Python 3.5 + was `google.auth == 1.23.0`. + +- Python 3.6: The last version of this library with support for Python 3.6 + was `google.auth == 2.22.0`. + +Documentation +------------- + +Google Auth Python Library has usage and reference documentation at https://googleapis.dev/python/google-auth/latest/index.html. + +Current Maintainers +------------------- +- googleapis-auth@google.com + +Authors +------- + +- `@theacodes `_ (Thea Flowers) +- `@dhermes `_ (Danny Hermes) +- `@lukesneeringer `_ (Luke Sneeringer) +- `@busunkim96 `_ (Bu Sun Kim) + +Contributing +------------ + +Contributions to this library are always welcome and highly encouraged. + +See `CONTRIBUTING.rst`_ for more information on how to get started. + +.. _CONTRIBUTING.rst: https://github.com/googleapis/google-cloud-python/blob/main/packages/google-auth/CONTRIBUTING.rst + +License +------- + +Apache 2.0 - See `the LICENSE`_ for more information. + +.. _the LICENSE: https://github.com/googleapis/google-cloud-python/blob/main/packages/google-auth/LICENSE diff --git a/packages/google-auth/docs/_static/custom.css b/packages/google-auth/docs/_static/custom.css new file mode 100644 index 000000000000..3d0319dd337c --- /dev/null +++ b/packages/google-auth/docs/_static/custom.css @@ -0,0 +1,16 @@ +@import url('https://fonts.googleapis.com/css?family=Roboto|Roboto+Mono'); + +@media screen and (min-width: 1080px) { + div.document { + width: 1040px; + } +} + +code.descname { + color: #4885ed; +} + +th.field-name { + min-width: 100px; + color: #3cba54; +} diff --git a/packages/google-auth/docs/conf.py b/packages/google-auth/docs/conf.py new file mode 100644 index 000000000000..41721eeec30f --- /dev/null +++ b/packages/google-auth/docs/conf.py @@ -0,0 +1,375 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# google-auth documentation build configuration file, created by +# sphinx-quickstart on Thu Sep 22 12:50:15 2016. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import google.auth + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", + "sphinx.ext.viewcode", + "sphinx.ext.napoleon", + "sphinx_docstring_typing", +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ["_templates"] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = ".rst" + +# The encoding of source files. +# +# source_encoding = 'utf-8-sig' + +# The root toctree document. +root_doc = "index" + +# General information about the project. +project = "google-auth" +copyright = "2016, Google, Inc." +author = "Google, Inc." + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = google.auth.__version__ +# The full version, including alpha/beta/rc tags. +release = version + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = "en" + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +# +# today = '' +# +# Else, today_fmt is used as the format for a strftime call. +# +# today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This patterns also effect to html_static_path and html_extra_path +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +# +# default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +# +# add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +# +add_module_names = False + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +# +# show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = "sphinx" + +# A list of ignored prefixes for module index sorting. +# modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +# keep_warnings = False + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = False + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = "alabaster" + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +html_theme_options = { + "description": "Google Auth Library for Python", + "github_user": "GoogleCloudPlatform", + "github_repo": "google-cloud-python", + "github_banner": True, + "travis_button": True, + "font_family": "'Roboto', Georgia, sans", + "head_font_family": "'Roboto', Georgia, serif", + "code_font_family": "'Roboto Mono', 'Consolas', monospace", +} + +# Add any paths that contain custom themes here, relative to this directory. +# html_theme_path = [] + +# The name for this set of Sphinx documents. +# " v documentation" by default. +# +# html_title = 'google-auth v0.0.1a' + +# A shorter title for the navigation bar. Default is the same as html_title. +# +# html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +# +# html_logo = None + +# The name of an image file (relative to this directory) to use as a favicon of +# the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +# +# html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ["_static"] + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +# +# html_extra_path = [] + +# If not None, a 'Last updated on:' timestamp is inserted at every page +# bottom, using the given strftime format. +# The empty string is equivalent to '%b %d, %Y'. +# +# html_last_updated_fmt = None + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +# +# html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +# + +html_sidebars = { + "**": ["about.html", "navigation.html", "relations.html", "searchbox.html"] +} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +# +# html_additional_pages = {} + +# If false, no module index is generated. +# +# html_domain_indices = True + +# If false, no index is generated. +# +# html_use_index = True + +# If true, the index is split into individual pages for each letter. +# +# html_split_index = False + +# If true, links to the reST sources are added to the pages. +# +# html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +# +# html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +# +# html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +# +# html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +# html_file_suffix = None + +# Language to be used for generating the HTML full-text search index. +# Sphinx supports the following languages: +# 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' +# 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr', 'zh' +# +# html_search_language = 'en' + +# A dictionary with options for the search language support, empty by default. +# 'ja' uses this config value. +# 'zh' user can custom change `jieba` dictionary path. +# +# html_search_options = {'type': 'default'} + +# The name of a javascript file (relative to the configuration directory) that +# implements a search results scorer. If empty, the default will be used. +# +# html_search_scorer = 'scorer.js' + +# Output file base name for HTML help builder. +htmlhelp_basename = "google-authdoc" + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (root_doc, "google-auth.tex", "google-auth Documentation", "Google, Inc.", "manual") +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +# +# latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +# +# latex_use_parts = False + +# If true, show page references after internal links. +# +# latex_show_pagerefs = False + +# If true, show URL addresses after external links. +# +# latex_show_urls = False + +# Documents to append as an appendix to all manuals. +# +# latex_appendices = [] + +# It false, will not define \strong, \code, itleref, \crossref ... but only +# \sphinxstrong, ..., \sphinxtitleref, ... To help avoid clash with user added +# packages. +# +# latex_keep_old_macro_names = True + +# If false, no module index is generated. +# +# latex_domain_indices = True + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [(root_doc, "google-auth", "google-auth Documentation", [author], 1)] + +# If true, show URL addresses after external links. +# +# man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ( + root_doc, + "google-auth", + "google-auth Documentation", + author, + "google-auth", + "One line description of project.", + "Miscellaneous", + ) +] + +# Documents to append as an appendix to all manuals. +# +# texinfo_appendices = [] + +# If false, no module index is generated. +# +# texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +# +# texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +# +# texinfo_no_detailmenu = False + + +# Example configuration for intersphinx: refer to the Python standard library. +intersphinx_mapping = { + "python": ("https://docs.python.org/3.5", None), + "urllib3": ("https://urllib3.readthedocs.io/en/stable", None), + "requests": ("https://requests.kennethreitz.org/en/master/", None), + "requests-oauthlib": ( + "https://requests-oauthlib.readthedocs.io/en/v1.3.0-docs/", + None, + ), +} + +# Autodoc config +autoclass_content = "both" +autodoc_member_order = "bysource" +autodoc_mock_imports = ["grpc"] diff --git a/packages/google-auth/docs/index.rst b/packages/google-auth/docs/index.rst new file mode 100644 index 000000000000..a0a65778510c --- /dev/null +++ b/packages/google-auth/docs/index.rst @@ -0,0 +1,74 @@ +google-auth +=========== + +.. toctree:: + :hidden: + :maxdepth: 2 + + user-guide + Reference + +google-auth is the Google authentication library for Python. This library +provides the ability to authenticate to Google APIs using various methods. It +also provides integration with several HTTP libraries. + +- Support for Google :func:`Application Default Credentials `. +- Support for signing and verifying :mod:`JWTs `. +- Support for creating `Google ID Tokens `__. +- Support for verifying and decoding :mod:`ID Tokens `. +- Support for Google :mod:`Service Account credentials `. +- Support for Google :mod:`Impersonated Credentials `. +- Support for :mod:`Google Compute Engine credentials `. +- Support for :mod:`Google App Engine standard credentials `. +- Support for :mod:`Identity Pool credentials `. +- Support for :mod:`AWS credentials `. +- Support for :mod:`Downscoping with Credential Access Boundaries credentials `. +- Support for various transports, including + :mod:`Requests `, + :mod:`urllib3 `, and + :mod:`gRPC `. + +.. note:: ``oauth2client`` was recently deprecated in favor of this library. For more details on the deprecation, see :doc:`oauth2client-deprecation`. + +Installing +---------- + +google-auth can be installed with `pip`_:: + + $ pip install --upgrade google-auth + +google-auth is open-source, so you can alternatively grab the source code from +`GitHub`_ and install from source. + + +For more information on setting up your Python development environment, please refer to `Python Development Environment Setup Guide`_ for Google Cloud Platform. + +.. _`Python Development Environment Setup Guide`: https://cloud.google.com/python/setup +.. _pip: https://pip.pypa.io +.. _GitHub: https://github.com/googleapis/google-cloud-python/packages/google-auth + +Usage +----- + +The :doc:`user-guide` is the place to go to learn how to use the library and +accomplish common tasks. + +The :doc:`Module Reference ` documentation provides API-level documentation. + +License +------- + +google-auth is made available under the Apache License, Version 2.0. For more +details, see `LICENSE`_ + +.. _LICENSE: + https://github.com/googleapis/google-cloud-python/blob/main/packages/google-auth/LICENSE + +Contributing +------------ + +We happily welcome contributions, please see our `contributing`_ documentation +for details. + +.. _contributing: + https://github.com/googleapis/google-cloud-python/blob/main/packages/google-auth/CONTRIBUTING.rst diff --git a/packages/google-auth/docs/oauth2client-deprecation.rst b/packages/google-auth/docs/oauth2client-deprecation.rst new file mode 100644 index 000000000000..2802c3e31e5c --- /dev/null +++ b/packages/google-auth/docs/oauth2client-deprecation.rst @@ -0,0 +1,117 @@ +:orphan: + +oauth2client deprecation +======================== + +This page is intended for existing users of the `oauth2client`_ who want to +understand the reasons for its deprecation and how this library relates to +``oauth2client``. + +.. _oauth2client: https://github.com/google/oauth2client + +Reasons for deprecation +----------------------- + +#. Lack of ownership: ``oauth2client`` has lacked a definitive owner since + around 2013. +#. Fragile and ad-hoc design: ``oauth2client`` is the result of several years + of ad-hoc additions and organic, uncontrolled growth. This has led to a + library that lacks overall design and cohesion. The convoluted class + hierarchy is a symptom of this. +#. Lack of a secure, thread-safe, and modern transport: ``oauth2client`` is + inextricably dependent on `httplib2`_. ``httplib2`` is largely unmaintained + (although recently there are a small group of volunteers attempting to + maintain it). +#. Lack of clear purpose and goals: The library is named "oauth2client" but is + actually a pretty poor OAuth 2.0 client and does a lot of things that have + nothing to do with OAuth and its related RFCs. + +.. _httplib2: https://github.com/httplib2/httplib2 + +We originally planned to address these issues within ``oauth2client``, however, +we determined that the number of breaking changes needed would be absolutely +untenable for downstream users. It would essentially involve our users having +to rewrite significant portions of their code if they needed to upgrade (either +directly or indirectly through a dependency). Instead, we've chosen to create a +new replacement library that can live side-by-side with ``oauth2client`` and +allow users to migrate gradually. We believe that this was the least painful +option. + +Replacement +----------- + +The long-term replacement for ``oauth2client`` is this library, +``google-auth``. This library addresses the major issues with oauthclient: + +#. Clear ownership: google-auth is owned by the teams that maintain the + `Cloud Client Libraries`_, `gRPC`_, and the + `Code Samples for Google Cloud Platform`_. +#. Thought-out design: using the lessons learned from ``oauth2client``, we have + designed a better module and class hierarchy. The ``v1.0.0`` release of this + library should provide long-term API stability. +#. Modern, secure, and extensible transports: ``google-auth`` supports + `urllib3`_, `requests`_, `gRPC`_, and has `legacy support for httplib2`_ to + help clients migration. It is transport agnostic and has explicit support + for adding new transports. +#. Clear purpose and goals: ``google-auth`` is explicitly focused on + Google-specific authentication, especially the server-to-server (service + account) use case. + +Because we reduced the scope of the library, there are several features in +``oauth2client`` we intentionally are not supporting in the ``v1.0.0`` release +of ``google-auth``. This does not mean we are not interested in supporting +these features, we just didn't feel they should be part of the initial API. +As downstream users ask for these features we will determine the best way to +serve those use cases without allowing the library to become a dumping ground. + +The unsupported features are: + +#. Support for obtaining user credentials. While this library has support for + using user credentials, there are no provisions in the core library for + doing the three-party OAuth 2.0 flow to obtain authorization from a user. + Instead, we are opting to provide a separate package that does integration + with `oauthlib`_, `google-auth-oauthlib`_. When that library has a stable + API, we will consider its inclusion into the core library. +#. Support for storing credentials. The only credentials type that needs to + be stored are user credentials. We have a `discussion thread`_ on what level + of support we should do. It's very likely we'll choose to provide an + abstract interface for this and leave it up to application to provide + storage implementation specific to their use case. It's also very likely + that we will also incubate this functionality in the + `google-auth-oauthlib`_ library before including it in the core library. + +.. _Cloud Client Libraries: https://github.com/googlecloudplatform/google-cloud-python +.. _gRPC: http://www.grpc.io/ +.. _Code Samples for Google Cloud Platform: https://github.com/googlecloudplatform/python-docs-samples +.. _urllib3: https://urllib3.readthedocs.io +.. _requests: http://python-requests.org +.. _legacy support for httplib2: https://pypi.python.org/pypi/google-auth-httplib2 +.. _oauthlib: https://oauthlib.readthedocs.io +.. _google-auth-oauthlib: http://google-auth-oauthlib.readthedocs.io/ +.. _discussion thread: https://github.com/GoogleCloudPlatform/google-auth-library-python/issues/33 + + +Post-deprecation support +------------------------ + +While ``oauth2client`` will not be implementing or accepting any new features, +the ``google-auth`` team will continue to accept bug reports and fix critical +bugs. We will make patch releases as necessary. We have no plans to remove the +library from GitHub or PyPI. Also, we have made sure that the +`google-api-python-client`_ library supports oauth2client and google-auth and +will continue to do so indefinitely. + +It is important to note that we will not be adding any features, even if an +external user goes through the trouble of sending a pull request. This policy +is in place because without it we will perpetuate the circumstances that led +to ``oauth2client`` being in the semi-unmaintained state it was in previously. + +Some old documentation and examples may use ``oauth2client`` instead of +``google-auth``. We are working to update all of these but it does take a +significant amount of time. Since we are still iterating on user auth, some +samples that use user auth will not be updated until we have settled on a final +interface. If you find any samples you feel should be updated, please +`file a bug`_. + +.. _google-api-python-client: https://github.com/google/google-api-python-client +.. _file a bug: https://github.com/GoogleCloudPlatform/google-auth-library-python/issues diff --git a/packages/google-auth/docs/reference/google.auth._credentials_async.rst b/packages/google-auth/docs/reference/google.auth._credentials_async.rst new file mode 100644 index 000000000000..683139aa533e --- /dev/null +++ b/packages/google-auth/docs/reference/google.auth._credentials_async.rst @@ -0,0 +1,7 @@ +google.auth.credentials\_async module +===================================== + +.. automodule:: google.auth._credentials_async + :members: + :inherited-members: + :show-inheritance: diff --git a/packages/google-auth/docs/reference/google.auth._jwt_async.rst b/packages/google-auth/docs/reference/google.auth._jwt_async.rst new file mode 100644 index 000000000000..d27984b6c5d6 --- /dev/null +++ b/packages/google-auth/docs/reference/google.auth._jwt_async.rst @@ -0,0 +1,7 @@ +google.auth.jwt\_async module +============================= + +.. automodule:: google.auth._jwt_async + :members: + :inherited-members: + :show-inheritance: diff --git a/packages/google-auth/docs/reference/google.auth.app_engine.rst b/packages/google-auth/docs/reference/google.auth.app_engine.rst new file mode 100644 index 000000000000..2142b6f50e8f --- /dev/null +++ b/packages/google-auth/docs/reference/google.auth.app_engine.rst @@ -0,0 +1,7 @@ +google.auth.app\_engine module +============================== + +.. automodule:: google.auth.app_engine + :members: + :inherited-members: + :show-inheritance: diff --git a/packages/google-auth/docs/reference/google.auth.aws.rst b/packages/google-auth/docs/reference/google.auth.aws.rst new file mode 100644 index 000000000000..9c3966bbad73 --- /dev/null +++ b/packages/google-auth/docs/reference/google.auth.aws.rst @@ -0,0 +1,7 @@ +google.auth.aws module +====================== + +.. automodule:: google.auth.aws + :members: + :inherited-members: + :show-inheritance: diff --git a/packages/google-auth/docs/reference/google.auth.compute_engine.credentials.rst b/packages/google-auth/docs/reference/google.auth.compute_engine.credentials.rst new file mode 100644 index 000000000000..782d95f203c6 --- /dev/null +++ b/packages/google-auth/docs/reference/google.auth.compute_engine.credentials.rst @@ -0,0 +1,7 @@ +google.auth.compute\_engine.credentials module +============================================== + +.. automodule:: google.auth.compute_engine.credentials + :members: + :inherited-members: + :show-inheritance: diff --git a/packages/google-auth/docs/reference/google.auth.compute_engine.rst b/packages/google-auth/docs/reference/google.auth.compute_engine.rst new file mode 100644 index 000000000000..819248cb5fab --- /dev/null +++ b/packages/google-auth/docs/reference/google.auth.compute_engine.rst @@ -0,0 +1,15 @@ +google.auth.compute\_engine package +=================================== + +.. automodule:: google.auth.compute_engine + :members: + :inherited-members: + :show-inheritance: + +Submodules +---------- + +.. toctree:: + :maxdepth: 4 + + google.auth.compute_engine.credentials diff --git a/packages/google-auth/docs/reference/google.auth.credentials.rst b/packages/google-auth/docs/reference/google.auth.credentials.rst new file mode 100644 index 000000000000..18d1d8cdf2f6 --- /dev/null +++ b/packages/google-auth/docs/reference/google.auth.credentials.rst @@ -0,0 +1,7 @@ +google.auth.credentials module +============================== + +.. automodule:: google.auth.credentials + :members: + :inherited-members: + :show-inheritance: diff --git a/packages/google-auth/docs/reference/google.auth.crypt.base.rst b/packages/google-auth/docs/reference/google.auth.crypt.base.rst new file mode 100644 index 000000000000..a8996501a7a2 --- /dev/null +++ b/packages/google-auth/docs/reference/google.auth.crypt.base.rst @@ -0,0 +1,7 @@ +google.auth.crypt.base module +============================= + +.. automodule:: google.auth.crypt.base + :members: + :inherited-members: + :show-inheritance: diff --git a/packages/google-auth/docs/reference/google.auth.crypt.es256.rst b/packages/google-auth/docs/reference/google.auth.crypt.es256.rst new file mode 100644 index 000000000000..5a6318482998 --- /dev/null +++ b/packages/google-auth/docs/reference/google.auth.crypt.es256.rst @@ -0,0 +1,7 @@ +google.auth.crypt.es256 module +============================== + +.. automodule:: google.auth.crypt.es256 + :members: + :inherited-members: + :show-inheritance: diff --git a/packages/google-auth/docs/reference/google.auth.crypt.rsa.rst b/packages/google-auth/docs/reference/google.auth.crypt.rsa.rst new file mode 100644 index 000000000000..7060b03c82e3 --- /dev/null +++ b/packages/google-auth/docs/reference/google.auth.crypt.rsa.rst @@ -0,0 +1,7 @@ +google.auth.crypt.rsa module +============================ + +.. automodule:: google.auth.crypt.rsa + :members: + :inherited-members: + :show-inheritance: diff --git a/packages/google-auth/docs/reference/google.auth.crypt.rst b/packages/google-auth/docs/reference/google.auth.crypt.rst new file mode 100644 index 000000000000..ff38fa34ea84 --- /dev/null +++ b/packages/google-auth/docs/reference/google.auth.crypt.rst @@ -0,0 +1,17 @@ +google.auth.crypt package +========================= + +.. automodule:: google.auth.crypt + :members: + :inherited-members: + :show-inheritance: + +Submodules +---------- + +.. toctree:: + :maxdepth: 4 + + google.auth.crypt.base + google.auth.crypt.es256 + google.auth.crypt.rsa diff --git a/packages/google-auth/docs/reference/google.auth.downscoped.rst b/packages/google-auth/docs/reference/google.auth.downscoped.rst new file mode 100644 index 000000000000..79668f998cd5 --- /dev/null +++ b/packages/google-auth/docs/reference/google.auth.downscoped.rst @@ -0,0 +1,7 @@ +google.auth.downscoped module +============================= + +.. automodule:: google.auth.downscoped + :members: + :inherited-members: + :show-inheritance: diff --git a/packages/google-auth/docs/reference/google.auth.environment_vars.rst b/packages/google-auth/docs/reference/google.auth.environment_vars.rst new file mode 100644 index 000000000000..5996e993596c --- /dev/null +++ b/packages/google-auth/docs/reference/google.auth.environment_vars.rst @@ -0,0 +1,7 @@ +google.auth.environment\_vars module +==================================== + +.. automodule:: google.auth.environment_vars + :members: + :inherited-members: + :show-inheritance: diff --git a/packages/google-auth/docs/reference/google.auth.exceptions.rst b/packages/google-auth/docs/reference/google.auth.exceptions.rst new file mode 100644 index 000000000000..c87a7f2afef9 --- /dev/null +++ b/packages/google-auth/docs/reference/google.auth.exceptions.rst @@ -0,0 +1,7 @@ +google.auth.exceptions module +============================= + +.. automodule:: google.auth.exceptions + :members: + :inherited-members: + :show-inheritance: diff --git a/packages/google-auth/docs/reference/google.auth.external_account.rst b/packages/google-auth/docs/reference/google.auth.external_account.rst new file mode 100644 index 000000000000..0681eaa277a2 --- /dev/null +++ b/packages/google-auth/docs/reference/google.auth.external_account.rst @@ -0,0 +1,7 @@ +google.auth.external\_account module +==================================== + +.. automodule:: google.auth.external_account + :members: + :inherited-members: + :show-inheritance: diff --git a/packages/google-auth/docs/reference/google.auth.iam.rst b/packages/google-auth/docs/reference/google.auth.iam.rst new file mode 100644 index 000000000000..8472ed731aca --- /dev/null +++ b/packages/google-auth/docs/reference/google.auth.iam.rst @@ -0,0 +1,7 @@ +google.auth.iam module +====================== + +.. automodule:: google.auth.iam + :members: + :inherited-members: + :show-inheritance: diff --git a/packages/google-auth/docs/reference/google.auth.identity_pool.rst b/packages/google-auth/docs/reference/google.auth.identity_pool.rst new file mode 100644 index 000000000000..48d9902236dd --- /dev/null +++ b/packages/google-auth/docs/reference/google.auth.identity_pool.rst @@ -0,0 +1,7 @@ +google.auth.identity\_pool module +================================= + +.. automodule:: google.auth.identity_pool + :members: + :inherited-members: + :show-inheritance: diff --git a/packages/google-auth/docs/reference/google.auth.impersonated_credentials.rst b/packages/google-auth/docs/reference/google.auth.impersonated_credentials.rst new file mode 100644 index 000000000000..f139ccf4f66f --- /dev/null +++ b/packages/google-auth/docs/reference/google.auth.impersonated_credentials.rst @@ -0,0 +1,7 @@ +google.auth.impersonated\_credentials module +============================================ + +.. automodule:: google.auth.impersonated_credentials + :members: + :inherited-members: + :show-inheritance: diff --git a/packages/google-auth/docs/reference/google.auth.jwt.rst b/packages/google-auth/docs/reference/google.auth.jwt.rst new file mode 100644 index 000000000000..c7c2fdf62e7c --- /dev/null +++ b/packages/google-auth/docs/reference/google.auth.jwt.rst @@ -0,0 +1,7 @@ +google.auth.jwt module +====================== + +.. automodule:: google.auth.jwt + :members: + :inherited-members: + :show-inheritance: diff --git a/packages/google-auth/docs/reference/google.auth.rst b/packages/google-auth/docs/reference/google.auth.rst new file mode 100644 index 000000000000..06cc2676bee7 --- /dev/null +++ b/packages/google-auth/docs/reference/google.auth.rst @@ -0,0 +1,37 @@ +google.auth package +=================== + +.. automodule:: google.auth + :members: + :inherited-members: + :show-inheritance: + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + google.auth.compute_engine + google.auth.crypt + google.auth.transport + +Submodules +---------- + +.. toctree:: + :maxdepth: 4 + + google.auth.app_engine + google.auth.aws + google.auth.credentials + google.auth._credentials_async + google.auth.downscoped + google.auth.environment_vars + google.auth.exceptions + google.auth.external_account + google.auth.iam + google.auth.identity_pool + google.auth.impersonated_credentials + google.auth.jwt + google.auth._jwt_async diff --git a/packages/google-auth/docs/reference/google.auth.transport._aiohttp_requests.rst b/packages/google-auth/docs/reference/google.auth.transport._aiohttp_requests.rst new file mode 100644 index 000000000000..44fc4e577514 --- /dev/null +++ b/packages/google-auth/docs/reference/google.auth.transport._aiohttp_requests.rst @@ -0,0 +1,7 @@ +google.auth.transport.aiohttp\_requests module +============================================== + +.. automodule:: google.auth.transport._aiohttp_requests + :members: + :inherited-members: + :show-inheritance: diff --git a/packages/google-auth/docs/reference/google.auth.transport.grpc.rst b/packages/google-auth/docs/reference/google.auth.transport.grpc.rst new file mode 100644 index 000000000000..f9f34429558a --- /dev/null +++ b/packages/google-auth/docs/reference/google.auth.transport.grpc.rst @@ -0,0 +1,7 @@ +google.auth.transport.grpc module +================================= + +.. automodule:: google.auth.transport.grpc + :members: + :inherited-members: + :show-inheritance: diff --git a/packages/google-auth/docs/reference/google.auth.transport.mtls.rst b/packages/google-auth/docs/reference/google.auth.transport.mtls.rst new file mode 100644 index 000000000000..11b50e23cb78 --- /dev/null +++ b/packages/google-auth/docs/reference/google.auth.transport.mtls.rst @@ -0,0 +1,7 @@ +google.auth.transport.mtls module +================================= + +.. automodule:: google.auth.transport.mtls + :members: + :inherited-members: + :show-inheritance: diff --git a/packages/google-auth/docs/reference/google.auth.transport.requests.rst b/packages/google-auth/docs/reference/google.auth.transport.requests.rst new file mode 100644 index 000000000000..5f0c23c2db24 --- /dev/null +++ b/packages/google-auth/docs/reference/google.auth.transport.requests.rst @@ -0,0 +1,7 @@ +google.auth.transport.requests module +===================================== + +.. automodule:: google.auth.transport.requests + :members: + :inherited-members: + :show-inheritance: diff --git a/packages/google-auth/docs/reference/google.auth.transport.rst b/packages/google-auth/docs/reference/google.auth.transport.rst new file mode 100644 index 000000000000..f1d198855d50 --- /dev/null +++ b/packages/google-auth/docs/reference/google.auth.transport.rst @@ -0,0 +1,19 @@ +google.auth.transport package +============================= + +.. automodule:: google.auth.transport + :members: + :inherited-members: + :show-inheritance: + +Submodules +---------- + +.. toctree:: + :maxdepth: 4 + + google.auth.transport._aiohttp_requests + google.auth.transport.grpc + google.auth.transport.mtls + google.auth.transport.requests + google.auth.transport.urllib3 diff --git a/packages/google-auth/docs/reference/google.auth.transport.urllib3.rst b/packages/google-auth/docs/reference/google.auth.transport.urllib3.rst new file mode 100644 index 000000000000..667bb0978c09 --- /dev/null +++ b/packages/google-auth/docs/reference/google.auth.transport.urllib3.rst @@ -0,0 +1,7 @@ +google.auth.transport.urllib3 module +==================================== + +.. automodule:: google.auth.transport.urllib3 + :members: + :inherited-members: + :show-inheritance: diff --git a/packages/google-auth/docs/reference/google.oauth2._credentials_async.rst b/packages/google-auth/docs/reference/google.oauth2._credentials_async.rst new file mode 100644 index 000000000000..d0df1e8a38e1 --- /dev/null +++ b/packages/google-auth/docs/reference/google.oauth2._credentials_async.rst @@ -0,0 +1,7 @@ +google.oauth2.credentials\_async module +======================================= + +.. automodule:: google.oauth2._credentials_async + :members: + :inherited-members: + :show-inheritance: diff --git a/packages/google-auth/docs/reference/google.oauth2._service_account_async.rst b/packages/google-auth/docs/reference/google.oauth2._service_account_async.rst new file mode 100644 index 000000000000..8aba0d8516f1 --- /dev/null +++ b/packages/google-auth/docs/reference/google.oauth2._service_account_async.rst @@ -0,0 +1,7 @@ +google.oauth2.service\_account\_async module +============================================ + +.. automodule:: google.oauth2._service_account_async + :members: + :inherited-members: + :show-inheritance: diff --git a/packages/google-auth/docs/reference/google.oauth2.credentials.rst b/packages/google-auth/docs/reference/google.oauth2.credentials.rst new file mode 100644 index 000000000000..d3bdc16de652 --- /dev/null +++ b/packages/google-auth/docs/reference/google.oauth2.credentials.rst @@ -0,0 +1,7 @@ +google.oauth2.credentials module +================================ + +.. automodule:: google.oauth2.credentials + :members: + :inherited-members: + :show-inheritance: diff --git a/packages/google-auth/docs/reference/google.oauth2.id_token.rst b/packages/google-auth/docs/reference/google.oauth2.id_token.rst new file mode 100644 index 000000000000..fbe6eab8dade --- /dev/null +++ b/packages/google-auth/docs/reference/google.oauth2.id_token.rst @@ -0,0 +1,7 @@ +google.oauth2.id\_token module +============================== + +.. automodule:: google.oauth2.id_token + :members: + :inherited-members: + :show-inheritance: diff --git a/packages/google-auth/docs/reference/google.oauth2.rst b/packages/google-auth/docs/reference/google.oauth2.rst new file mode 100644 index 000000000000..2a8a7a5885d2 --- /dev/null +++ b/packages/google-auth/docs/reference/google.oauth2.rst @@ -0,0 +1,21 @@ +google.oauth2 package +===================== + +.. automodule:: google.oauth2 + :members: + :inherited-members: + :show-inheritance: + +Submodules +---------- + +.. toctree:: + :maxdepth: 4 + + google.oauth2.credentials + google.oauth2._credentials_async + google.oauth2.id_token + google.oauth2.service_account + google.oauth2._service_account_async + google.oauth2.sts + google.oauth2.utils diff --git a/packages/google-auth/docs/reference/google.oauth2.service_account.rst b/packages/google-auth/docs/reference/google.oauth2.service_account.rst new file mode 100644 index 000000000000..8d8fcd3f1af9 --- /dev/null +++ b/packages/google-auth/docs/reference/google.oauth2.service_account.rst @@ -0,0 +1,7 @@ +google.oauth2.service\_account module +===================================== + +.. automodule:: google.oauth2.service_account + :members: + :inherited-members: + :show-inheritance: diff --git a/packages/google-auth/docs/reference/google.oauth2.sts.rst b/packages/google-auth/docs/reference/google.oauth2.sts.rst new file mode 100644 index 000000000000..49d99dfe66f0 --- /dev/null +++ b/packages/google-auth/docs/reference/google.oauth2.sts.rst @@ -0,0 +1,7 @@ +google.oauth2.sts module +======================== + +.. automodule:: google.oauth2.sts + :members: + :inherited-members: + :show-inheritance: diff --git a/packages/google-auth/docs/reference/google.oauth2.utils.rst b/packages/google-auth/docs/reference/google.oauth2.utils.rst new file mode 100644 index 000000000000..5b039eac825c --- /dev/null +++ b/packages/google-auth/docs/reference/google.oauth2.utils.rst @@ -0,0 +1,7 @@ +google.oauth2.utils module +========================== + +.. automodule:: google.oauth2.utils + :members: + :inherited-members: + :show-inheritance: diff --git a/packages/google-auth/docs/reference/google.rst b/packages/google-auth/docs/reference/google.rst new file mode 100644 index 000000000000..f122ca13c85b --- /dev/null +++ b/packages/google-auth/docs/reference/google.rst @@ -0,0 +1,16 @@ +google package +============== + +.. automodule:: google + :members: + :inherited-members: + :show-inheritance: + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + google.auth + google.oauth2 diff --git a/packages/google-auth/docs/reference/modules.rst b/packages/google-auth/docs/reference/modules.rst new file mode 100644 index 000000000000..b1816cc9b408 --- /dev/null +++ b/packages/google-auth/docs/reference/modules.rst @@ -0,0 +1,7 @@ +google +====== + +.. toctree:: + :maxdepth: 4 + + google diff --git a/packages/google-auth/docs/requirements-docs.txt b/packages/google-auth/docs/requirements-docs.txt new file mode 100644 index 000000000000..8b2c192dd08d --- /dev/null +++ b/packages/google-auth/docs/requirements-docs.txt @@ -0,0 +1,17 @@ +cryptography +sphinx-docstring-typing +urllib3 +requests +requests-oauthlib +# We need to pin to specific versions of the `sphinxcontrib-*` packages +# which still support sphinx 4.x. +# See https://github.com/googleapis/sphinx-docfx-yaml/issues/344 +# and https://github.com/googleapis/sphinx-docfx-yaml/issues/345. +sphinxcontrib-applehelp==1.0.4 +sphinxcontrib-devhelp==1.0.2 +sphinxcontrib-htmlhelp==2.0.1 +sphinxcontrib-qthelp==1.0.3 +sphinxcontrib-serializinghtml==1.1.5 +sphinx==4.5.0 +alabaster +recommonmark diff --git a/packages/google-auth/docs/user-guide.rst b/packages/google-auth/docs/user-guide.rst new file mode 100644 index 000000000000..67d04174db42 --- /dev/null +++ b/packages/google-auth/docs/user-guide.rst @@ -0,0 +1,1451 @@ +User Guide +========== + +.. currentmodule:: google.auth + +Credentials and account types +----------------------------- + +:class:`~credentials.Credentials` are the means of identifying an application or +user to a service or API. Credentials can be obtained with three different types +of accounts: *service accounts*, *user accounts* and *external accounts*. + +Credentials from service accounts identify a particular application. These types +of credentials are used in server-to-server use cases, such as accessing a +database. This library primarily focuses on service account credentials. + +Credentials from user accounts are obtained by asking the user to authorize +access to their data. These types of credentials are used in cases where your +application needs access to a user's data in another service, such as accessing +a user's documents in Google Drive. This library provides no support for +obtaining user credentials, but does provide limited support for using user +credentials. + +Credentials from external accounts (workload identity federation) are used to +identify a particular application from an on-prem or non-Google Cloud platform +including Amazon Web Services (AWS), Microsoft Azure, any identity provider +that supports OpenID Connect (OIDC), or via X.509 certificates. + +Obtaining credentials +--------------------- + +.. warning:: + Important: If you accept a credential configuration (credential JSON/File/Stream) + from an external source for authentication to Google Cloud Platform, you must + validate it before providing it to any Google API or client library. Providing an + unvalidated credential configuration to Google APIs or libraries can compromise + the security of your systems and data. For more information, refer to + `Validate credential configurations from external sources`_. + +.. _Validate credential configurations from external sources: + https://cloud.google.com/docs/authentication/external/externally-sourced-credentials + +.. _application-default: + +Application default credentials ++++++++++++++++++++++++++++++++ + +`Google Application Default Credentials`_ abstracts authentication across the +different Google Cloud Platform hosting environments. When running on any Google +Cloud hosting environment or when running locally with the `Google Cloud SDK`_ +installed, :func:`default` can automatically determine the credentials from the +environment:: + + import google.auth + + credentials, project = google.auth.default() + +If your application requires specific scopes:: + + credentials, project = google.auth.default( + scopes=['https://www.googleapis.com/auth/cloud-platform']) + +Application Default Credentials also support workload identity federation to +access Google Cloud resources from non-Google Cloud platforms including Amazon +Web Services (AWS), Microsoft Azure or any identity provider that supports +OpenID Connect (OIDC). Workload identity federation is recommended for +non-Google Cloud environments as it avoids the need to download, manage and +store service account private keys locally. + +.. _Google Application Default Credentials: + https://developers.google.com/identity/protocols/ + application-default-credentials +.. _Google Cloud SDK: https://cloud.google.com/sdk + + +Compute Engine, Container Engine, and the App Engine flexible environment ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +Applications running on `Compute Engine`_, `Container Engine`_, or the `App +Engine flexible environment`_ can obtain credentials provided by `Compute +Engine service accounts`_. When running on these platforms you can obtain +credentials for the service account one of two ways: + +1. Use :ref:`application default credentials `. + :func:`default` will automatically detect if these credentials are available. + +2. Use :class:`compute_engine.Credentials`:: + + from google.auth import compute_engine + + credentials = compute_engine.Credentials() + +.. _Compute Engine: https://cloud.google.com/compute +.. _Container Engine: https://cloud.google.com/container-engine +.. _App Engine flexible environment: + https://cloud.google.com/appengine/docs/flexible/ +.. _Compute Engine service accounts: + https://cloud.google.com/compute/docs/access/service-accounts + +The App Engine standard environment ++++++++++++++++++++++++++++++++++++ + +Applications running on the `App Engine standard environment`_ can obtain +credentials provided by the `App Engine App Identity API`_. You can obtain +credentials one of two ways: + +1. Use :ref:`application default credentials `. + :func:`default` will automatically detect if these credentials are available. + +2. Use :class:`app_engine.Credentials`:: + + from google.auth import app_engine + + credentials = app_engine.Credentials() + +In order to make authenticated requests in the App Engine environment using the +credentials and transports provided by this library, you need to follow a few +additional steps: + +#. If you are using the :mod:`google.auth.transport.requests` transport, vendor + in the `requests-toolbelt`_ library into your app, and enable the App Engine + monkeypatch. Refer `App Engine documentation`_ for more details on this. +#. To make HTTPS calls, enable the ``ssl`` library for your app by adding the + following configuration to the ``app.yaml`` file:: + + libraries: + - name: ssl + version: latest + +#. Enable billing for your App Engine project. Then enable socket support for + your app. This can be achieved by setting an environment variable in the + ``app.yaml`` file:: + + env_variables: + GAE_USE_SOCKETS_HTTPLIB : 'true' + +.. _App Engine standard environment: + https://cloud.google.com/appengine/docs/python +.. _App Engine App Identity API: + https://cloud.google.com/appengine/docs/python/appidentity/ +.. _requests-toolbelt: + https://toolbelt.readthedocs.io/en/latest/ +.. _App Engine documentation: + https://cloud.google.com/appengine/docs/standard/python/issue-requests + +User credentials +++++++++++++++++ + +User credentials are typically obtained via `OAuth 2.0`_. This library does not +provide any direct support for *obtaining* user credentials, however, you can +use user credentials with this library. You can use libraries such as +`oauthlib`_ to obtain the access token. After you have an access token, you +can create a :class:`google.oauth2.credentials.Credentials` instance:: + + import google.oauth2.credentials + + credentials = google.oauth2.credentials.Credentials( + 'access_token') + +If you obtain a refresh token, you can also specify the refresh token and token +URI to allow the credentials to be automatically refreshed:: + + credentials = google.oauth2.credentials.Credentials( + 'access_token', + refresh_token='refresh_token', + token_uri='token_uri', + client_id='client_id', + client_secret='client_secret') + + +There is a separate library, `google-auth-oauthlib`_, that has some helpers +for integrating with `requests-oauthlib`_ to provide support for obtaining +user credentials. You can use +:func:`google_auth_oauthlib.helpers.credentials_from_session` to obtain +:class:`google.oauth2.credentials.Credentials` from a +:class:`requests_oauthlib.OAuth2Session` as above:: + + from google_auth_oauthlib.helpers import credentials_from_session + + google_auth_credentials = credentials_from_session(oauth2session) + +You can also use :class:`google_auth_oauthlib.flow.Flow` to perform the OAuth +2.0 Authorization Grant Flow to obtain credentials using `requests-oauthlib`_. + +.. _OAuth 2.0: + https://developers.google.com/identity/protocols/OAuth2 +.. _oauthlib: + https://oauthlib.readthedocs.io/en/latest/ +.. _google-auth-oauthlib: + https://pypi.python.org/pypi/google-auth-oauthlib +.. _requests-oauthlib: + https://requests-oauthlib.readthedocs.io/en/latest/ + + +External credentials (Workload identity federation) ++++++++++++++++++++++++++++++++++++++++++++++++++++ + +Using workload identity federation, your application can access Google Cloud +resources from Amazon Web Services (AWS), Microsoft Azure, any identity +provider that supports OpenID Connect (OIDC), or via X.509 certificates. + +Traditionally, applications running outside Google Cloud have used service +account keys to access Google Cloud resources. Using identity federation, +you can allow your workload to impersonate a service account. +This lets you access Google Cloud resources directly, eliminating the +maintenance and security burden associated with service account keys. + +Accessing resources from AWS +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In order to access Google Cloud resources from Amazon Web Services (AWS), the +following requirements are needed: + +- A workload identity pool needs to be created. +- AWS needs to be added as an identity provider in the workload identity pool + (The Google organization policy needs to allow federation from AWS). +- Permission to impersonate a service account needs to be granted to the + external identity. +- A credential configuration file needs to be generated. Unlike service account + credential files, the generated credential configuration file will only + contain non-sensitive metadata to instruct the library on how to retrieve + external subject tokens and exchange them for service account access tokens. +- If you want to use IDMSv2, then below field needs to be added to credential_source + section of credential configuration. + "imdsv2_session_token_url": "http://169.254.169.254/latest/api/token" + +Follow the detailed instructions on how to +`Configure Workload Identity Federation from AWS`_. + +.. _Configure Workload Identity Federation from AWS: + https://cloud.google.com/iam/docs/access-resources-aws + +Accessing resources from Microsoft Azure +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In order to access Google Cloud resources from Microsoft Azure, the following +requirements are needed: + +- A workload identity pool needs to be created. +- Azure needs to be added as an identity provider in the workload identity pool + (The Google organization policy needs to allow federation from Azure). +- The Azure tenant needs to be configured for identity federation. +- Permission to impersonate a service account needs to be granted to the + external identity. +- A credential configuration file needs to be generated. Unlike service account + credential files, the generated credential configuration file will only + contain non-sensitive metadata to instruct the library on how to retrieve + external subject tokens and exchange them for service account access tokens. + +Follow the detailed instructions on how to +`Configure Workload Identity Federation from Microsoft Azure`_. + +.. _Configure Workload Identity Federation from Microsoft Azure: + https://cloud.google.com/iam/docs/access-resources-azure + +Accessing resources from an OIDC identity provider +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In order to access Google Cloud resources from an identity provider that +supports `OpenID Connect (OIDC)`_, the following requirements are needed: + +- A workload identity pool needs to be created. +- An OIDC identity provider needs to be added in the workload identity pool + (The Google organization policy needs to allow federation from the identity + provider). +- Permission to impersonate a service account needs to be granted to the + external identity. +- A credential configuration file needs to be generated. Unlike service account + credential files, the generated credential configuration file will only + contain non-sensitive metadata to instruct the library on how to retrieve + external subject tokens and exchange them for service account access tokens. + +For OIDC providers, the Auth library can retrieve OIDC tokens either from a +local file location (file-sourced credentials) or from a local server +(URL-sourced credentials). + +- For file-sourced credentials, a background process needs to be continuously + refreshing the file location with a new OIDC token prior to expiration. + For tokens with one hour lifetimes, the token needs to be updated in the file + every hour. The token can be stored directly as plain text or in JSON format. +- For URL-sourced credentials, a local server needs to host a GET endpoint to + return the OIDC token. The response can be in plain text or JSON. + Additional required request headers can also be specified. + +Follow the detailed instructions on how to +`Configure Workload Identity Federation from an OIDC identity provider`_. + +.. _OpenID Connect (OIDC): + https://openid.net/connect/ +.. _Configure Workload Identity Federation from an OIDC identity provider: + https://cloud.google.com/iam/docs/access-resources-oidc + +.. _accessing-resources-using-x509-certificate-sourced-credentials: + +Accessing resources using X.509 certificate-sourced credentials +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +For `X.509 certificate-sourced credentials`_, the authentication library uses an X.509 certificate and private key to prove your application's identity. The certificate has a built-in expiration date and must be renewed to maintain access. + +The library constructs a subject token by creating a JSON array containing the base64-encoded leaf certificate, followed by any intermediate certificates from a provided trust chain. + +**Generating Configuration Files for X.509 Federation** + +To configure X.509 certificate-sourced credentials, you need to generate two separate configuration files: a primary **credential configuration file** and a **certificate configuration file**. The ``gcloud iam workload-identity-pools create-cred-config`` command can be used to create both. + +The location where the certificate configuration file is created depends on whether you use the ``--credential-cert-configuration-output-file`` flag. + +**Default Behavior (Recommended)** + +If you omit the ``--credential-cert-configuration-output-file`` flag, gcloud creates the certificate configuration file at a default, well-known location that the auth library can automatically discover. This is the simplest approach for most use cases. + +**Example Command (Default Behavior):** + +.. code-block:: bash + + gcloud iam workload-identity-pools create-cred-config \ + projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$POOL_ID/providers/$PROVIDER_ID \ + --service-account $SERVICE_ACCOUNT_EMAIL \ + --credential-cert-path "$PATH_TO_CERTIFICATE" \ + --credential-cert-private-key-path "$PATH_TO_PRIVATE_KEY" \ + --credential-cert-trust-chain-path "$PATH_TO_TRUST_CHAIN" \ + --output-file /path/to/config.json + +Where the following variables need to be substituted: + +* ``$PROJECT_NUMBER``: The unique, numerical identifier for your Google Cloud project. This is not the Project ID string. +* ``$POOL_ID``: The workload identity pool ID. +* ``$PROVIDER_ID``: The provider ID. +* ``$SERVICE_ACCOUNT_EMAIL``: The email of the service account to impersonate. +* ``$PATH_TO_CERTIFICATE``: The file path where your leaf X.509 certificate is located. +* ``$PATH_TO_PRIVATE_KEY``: The file path where the corresponding private key for the leaf certificate is located. +* ``$PATH_TO_TRUST_CHAIN``: The file path of the X.509 certificate trust chain file. This file should be a PEM-formatted file containing any intermediate certificates required to complete the trust chain between the leaf certificate and the trust store configured in the Workload Identity Federation pool. The leaf certificate is optional in this file. + +This command results in: + +* ``/path/to/config.json``: Created at the path you specified. This file will contain ``"use_default_certificate_config": true`` to instruct clients to look for the certificate configuration at the default path. +* ``certificate_config.json``: Created at the default gcloud configuration path, which is typically ``~/.config/gcloud/certificate_config.json`` on Linux and macOS, or ``%APPDATA%\gcloud\certificate_config.json`` on Windows. + + +**Custom Location Behavior** + +If you need to store the certificate configuration file in a non-default location, use the ``--credential-cert-configuration-output-file`` flag. + +**Example Command (Custom Location):** + +.. code-block:: bash + + gcloud iam workload-identity-pools create-cred-config \ + projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$POOL_ID/providers/$PROVIDER_ID \ + --service-account $SERVICE_ACCOUNT_EMAIL \ + --credential-cert-path "$PATH_TO_CERTIFICATE" \ + --credential-cert-private-key-path "$PATH_TO_PRIVATE_KEY" \ + --credential-cert-trust-chain-path "$PATH_TO_TRUST_CHAIN" \ + --credential-cert-configuration-output-file "/custom/path/cert_config.json" \ + --output-file /path/to/config.json + + +This command results in: + +* ``/path/to/config.json``: Created at the path you specified. This file will contain a ``"certificate_config_location"`` field that points to your custom path. +* ``cert_config.json``: Created at ``/custom/path/cert_config.json``, as specified by the flag. + +You can now use the Auth library to call Google Cloud resources with X.509 certificate-sourced credentials. + +.. _X.509 certificate-sourced credentials: https://cloud.google.com/iam/docs/workload-identity-federation-with-x509-certificates + + +Using Executable-sourced credentials with OIDC and SAML +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +**Executable-sourced credentials** For executable-sourced credentials, a +local executable is used to retrieve the 3rd party token. The executable +must handle providing a valid, unexpired OIDC ID token or SAML assertion +in JSON format to stdout. + +To use executable-sourced credentials, the +``GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES`` environment variable must +be set to ``1``. + +To generate an executable-sourced workload identity configuration, run +the following command: + +.. code:: bash + + # Generate a configuration file for executable-sourced credentials. + gcloud iam workload-identity-pools create-cred-config \ + projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$POOL_ID/providers/$PROVIDER_ID \ + --service-account=$SERVICE_ACCOUNT_EMAIL \ + --subject-token-type=$SUBJECT_TOKEN_TYPE \ + # The absolute path for the program, including arguments. + # e.g. --executable-command="/path/to/command --foo=bar" + --executable-command=$EXECUTABLE_COMMAND \ + # Optional argument for the executable timeout. Defaults to 30s. + # --executable-timeout-millis=$EXECUTABLE_TIMEOUT \ + # Optional argument for the absolute path to the executable output file. + # See below on how this argument impacts the library behaviour. + # --executable-output-file=$EXECUTABLE_OUTPUT_FILE \ + --output-file /path/to/generated/config.json + +Where the following variables need to be substituted: - +``$PROJECT_NUMBER``: The Google Cloud project number. - ``$POOL_ID``: +The workload identity pool ID. - ``$PROVIDER_ID``: The OIDC or SAML +provider ID. - ``$SERVICE_ACCOUNT_EMAIL``: The email of the service +account to impersonate. - ``$SUBJECT_TOKEN_TYPE``: The subject token +type. - ``$EXECUTABLE_COMMAND``: The full command to run, including +arguments. Must be an absolute path to the program. + +The ``--executable-timeout-millis`` flag is optional. This is the +duration for which the auth library will wait for the executable to +finish, in milliseconds. Defaults to 30 seconds when not provided. The +maximum allowed value is 2 minutes. The minimum is 5 seconds. + +The ``--executable-output-file`` flag is optional. If provided, the file +path must point to the 3PI credential response generated by the +executable. This is useful for caching the credentials. By specifying +this path, the Auth libraries will first check for its existence before +running the executable. By caching the executable JSON response to this +file, it improves performance as it avoids the need to run the +executable until the cached credentials in the output file are expired. +The executable must handle writing to this file - the auth libraries +will only attempt to read from this location. The format of contents in +the file should match the JSON format expected by the executable shown +below. + +To retrieve the 3rd party token, the library will call the executable +using the command specified. The executable’s output must adhere to the +response format specified below. It must output the response to stdout. + +A sample successful executable OIDC response: + +.. code:: json + + { + "version": 1, + "success": true, + "token_type": "urn:ietf:params:oauth:token-type:id_token", + "id_token": "HEADER.PAYLOAD.SIGNATURE", + "expiration_time": 1620499962 + } + +A sample successful executable SAML response: + +.. code:: json + + { + "version": 1, + "success": true, + "token_type": "urn:ietf:params:oauth:token-type:saml2", + "saml_response": "...", + "expiration_time": 1620499962 + } + +A sample executable error response: + +.. code:: json + + { + "version": 1, + "success": false, + "code": "401", + "message": "Caller not authorized." + } + +These are all required fields for an error response. The code and +message fields will be used by the library as part of the thrown +exception. + +Response format fields summary: + +- ``version``: The version of the JSON output. Currently only version 1 is + supported. +- ``success``: The status of the response. + - When true, the response must contain the 3rd party token, token type, and + expiration. The executable must also exit with exit code 0. + - When false, the response must contain the error code and message fields + and exit with a non-zero value. +- ``token_type``: The 3rd party subject token type. Must be + - *urn:ietf:params:oauth:token-type:jwt* + - *urn:ietf:params:oauth:token-type:id_token* + - *urn:ietf:params:oauth:token-type:saml2* +- ``id_token``: The 3rd party OIDC token. +- ``saml_response``: The 3rd party SAML response. +- ``expiration_time``: The 3rd party subject token expiration time in seconds + (unix epoch time). +- ``code``: The error code string. +- ``message``: The error message. + +All response types must include both the ``version`` and ``success`` fields. +Successful responses must include the ``token_type``, and one of ``id_token`` +or ``saml_response``. +``expiration_time`` is optional. If the output file does not contain the +``expiration_time`` field, the response will be considered expired and the +executable will be called. +Error responses must include both the ``code`` and ``message`` fields. + +The library will populate the following environment variables when the +executable is run: ``GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE``: The audience +field from the credential configuration. Always present. +``GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL``: The service account +email. Only present when service account impersonation is used. +``GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE``: The output file location from +the credential configuration. Only present when specified in the +credential configuration. + +These environment variables can be used by the executable to avoid +hard-coding these values. + +Security considerations + + The following security practices are highly recommended: + Access to the script should be restricted as it will be displaying + credentials to stdout. This ensures that rogue processes do not gain + access to the script. The configuration file should not be + modifiable. Write access should be restricted to avoid processes + modifying the executable command portion. + +Given the complexity of using executable-sourced credentials, it is +recommended to use the existing supported mechanisms +(file-sourced/URL-sourced) for providing 3rd party credentials unless +they do not meet your specific requirements. + +You can now `use the Auth library <#using-external-identities>`__ to +call Google Cloud resources from an OIDC or SAML provider. + + +Accessing resources using a custom supplier with OIDC or SAML +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This library also allows for a custom implementation of :class:`google.auth.identity_pool.SubjectTokenSupplier` +to be specificed when creating a :class:`google.auth.identity_pool.Credential`. The supplier must +return a valid OIDC or SAML2.0 subject token, which will then be exchanged for a +Google Cloud access token. If an error occurs during token retrieval, the supplier +should return a :class:`google.auth.exceptions.RefreshError` and indicate via the error +whether the subject token retrieval is retryable. +Any call to the supplier from the Identity Pool credential will send a :class:`google.auth.external_account.SupplierContext` +object, which contains the requested audience and subject type. Additionally, the credential will +send the :class:`google.auth.transport.requests.Request` passed in the credential refresh call which +can be used to make HTTP requests.:: + + from google.auth import exceptions + from google.auth import identity_pool + + class CustomSubjectTokenSupplier(identity_pool.SubjectTokenSupplier): + + def get_subject_token(self, context, request): + audience = context.audience + subject_token_type = context.subject_token_type + try: + # Attempt to return the valid subject token of the requested type for the requested audience. + except Exception as e: + # If token retrieval fails, raise a refresh error, setting retryable to true if the client should + # attempt to retrieve the subject token again. + raise exceptions.RefreshError(e, retryable=True) + + supplier = CustomSubjectTokenSupplier() + + credentials = identity_pool.Credentials( + AUDIENCE, # Set GCP Audience. + "urn:ietf:params:aws:token-type:jwt", # Set subject token type. + subject_token_supplier=supplier, # Set supplier. + scopes=SCOPES # Set desired scopes. + ) + +Where the `audience`_ is: ``///iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID`` +Where the following variables need to be substituted: + +* ``$PROJECT_NUMBER``: The project number. +* ``$POOL_ID``: The workload pool ID. +* ``$PROVIDER_ID``: The provider ID. + +The values for audience, service account impersonation URL, and any other builder field can also be found +by generating a `credential configuration file with the gcloud CLI`_. + +.. _audience: + https://cloud.google.com/iam/docs/best-practices-for-using-workload-identity-federation#provider-audience +.. _credential configuration file with the gcloud CLI: + https://cloud.google.com/sdk/gcloud/reference/iam/workload-identity-pools/create-cred-config + +Accessing resources using a custom supplier with AWS +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This library also allows for a custom implementation of :class:`google.auth.aws.AwsSecurityCredentialsSupplier` +to be specificed when creating a :class:`google.auth.aws.Credential`. The supplier must +return valid AWS security credentials, which will then be exchanged for a +Google Cloud access token. If an error occurs during credential retrieval, the supplier +should return a :class:`google.auth.exceptions.RefreshError` and indicate via the error +whether the credential retrieval is retryable. +Any call to the supplier from the Identity Pool credential will send a :class:`google.auth.external_account.SupplierContext` +object, which contains the requested audience and subject type. Additionally, the credential will +send the :class:`google.auth.transport.requests.Request` passed in the credential refresh call which +can be used to make HTTP requests.:: + + from google.auth import aws + from google.auth import exceptions + + class CustomAwsSecurityCredentialsSupplier(aws.AwsSecurityCredentialsSupplier): + + def get_aws_security_credentials(self, context, request): + audience = context.audience + try: + # Return valid AWS security credentials. These credentials are not cached by + # the google credential, so caching should be implemented in the supplier. + return aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN) + except Exception as e: + # If credentials retrieval fails, raise a refresh error, setting retryable to true if the client should + # attempt to retrieve the subject token again. + raise exceptions.RefreshError(e, retryable=True) + + def get_aws_region(self, context, request): + # Return active AWS region. + + supplier = CustomAwsSecurityCredentialsSupplier() + + credentials = aws.Credentials( + AUDIENCE, # Set GCP Audience. + "urn:ietf:params:aws:token-type:aws4_request", # Set AWS subject token type. + aws_security_credentials_supplier=supplier, # Set supplier. + scopes=SCOPES # Set desired scopes. + ) + +Where the `audience`_ is: ``///iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID`` +Where the following variables need to be substituted: + +* ``$PROJECT_NUMBER``: The project number. +* ``$POOL_ID``: The workload pool ID. +* ``$PROVIDER_ID``: The provider ID. + +The values for audience, service account impersonation URL, and any other builder field can also be found +by generating a `credential configuration file with the gcloud CLI`_. + +.. _audience: + https://cloud.google.com/iam/docs/best-practices-for-using-workload-identity-federation#provider-audience +.. _credential configuration file with the gcloud CLI: + https://cloud.google.com/sdk/gcloud/reference/iam/workload-identity-pools/create-cred-config + +Using External Identities +~~~~~~~~~~~~~~~~~~~~~~~~~ + +External identities (AWS, Azure and OIDC identity providers) can be used with +Application Default Credentials. +In order to use external identities with Application Default Credentials, you +need to generate the JSON credentials configuration file for your external +identity. +Once generated, store the path to this file in the +``GOOGLE_APPLICATION_CREDENTIALS`` environment variable. + +.. code-block:: bash + + $ export GOOGLE_APPLICATION_CREDENTIALS=/path/to/config.json + +The library can now automatically choose the right type of client and initialize +credentials from the context provided in the configuration file:: + + import google.auth + + credentials, project = google.auth.default() + +When using external identities with Application Default Credentials, +the ``roles/browser`` role needs to be granted to the service account. +The ``Cloud Resource Manager API`` should also be enabled on the project. +This is needed since :func:`default` will try to auto-discover the project ID +from the current environment using the impersonated credential. +Otherwise, the project ID will resolve to ``None``. You can override the project +detection by setting the ``GOOGLE_CLOUD_PROJECT`` environment variable. + +You can also explicitly initialize external account clients using the generated +configuration file. + +For Azure and OIDC providers, use :meth:`identity_pool.Credentials.from_info +` or +:meth:`identity_pool.Credentials.from_file +`:: + + import json + + from google.auth import identity_pool + + json_config_info = json.loads(function_to_get_json_config()) + credentials = identity_pool.Credentials.from_info(json_config_info) + scoped_credentials = credentials.with_scopes( + ['https://www.googleapis.com/auth/cloud-platform']) + +For AWS providers, use :meth:`aws.Credentials.from_info +` or +:meth:`aws.Credentials.from_file +`:: + + import json + + from google.auth import aws + + json_config_info = json.loads(function_to_get_json_config()) + credentials = aws.Credentials.from_info(json_config_info) + scoped_credentials = credentials.with_scopes( + ['https://www.googleapis.com/auth/cloud-platform']) + + +Security considerations +~~~~~~~~~~~~~~~~~~~~~~~ + +Note that this library does not perform any validation on the token_url, +token_info_url, or service_account_impersonation_url fields of the credential +configuration. It is not recommended to use a credential configuration that you +did not generate with the gcloud CLI unless you verify that the URL fields point +to a googleapis.com domain. + + +External credentials (Workforce identity federation) +++++++++++++++++++++++++++++++++++++++++++++++++++++ + +`Workforce identity federation`_ lets you use an external identify provider +(IdP) to authenticate and authorize a workforce—a group of users, such as +employees, partners, and contractors—using IAM, so that the users can access +Google Cloud services. Workforce identity federation extends Google Cloud's +identity capabilities to support syncless, attribute-based single sign on. + +With workforce identity federation, your workforce can access Google Cloud +resources using an external identity provider (IdP) that supports OpenID +Connect (OIDC) or SAML 2.0 such as Azure Active Directory (Azure AD), Active +Directory Federation Services (AD FS), Okta, and others. + + +Accessing resources using an OIDC or SAML 2.0 identity provider +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In order to access Google Cloud resources from an identity provider that +supports `OpenID Connect (OIDC)`_, the following requirements are needed: + +- A workforce identity pool needs to be created. +- An OIDC or SAML 2.0 identity provider needs to be added in the workforce pool. + +Follow the detailed `instructions`_ on how to configure workforce identity +federation. + +After configuring an OIDC or SAML 2.0 provider, a credential configuration file +needs to be generated. The generated credential configuration file contains +non-sensitive metadata to instruct the library on how to retrieve external +subject tokens and exchange them for GCP access tokens. The configuration file +can be generated by using the `gcloud CLI`_. + +The Auth library can retrieve external subject tokens from a local file +location (file-sourced credentials), from a local server (URL-sourced +credentials) or by calling an executable (executable-sourced credentials). + +File-sourced credentials +++++++++++++++++++++++++ + +For file-sourced credentials, a background process needs to be continuously +refreshing the file location with a new subject token prior to expiration. For +tokens with one hour lifetimes, the token needs to be updated in the file every +hour. The token can be stored directly as plain text or in JSON format. + +To generate a file-sourced OIDC configuration, run the following command: + +.. code-block:: bash + + # Generate an OIDC configuration file for file-sourced credentials. + gcloud iam workforce-pools create-cred-config \ + locations/global/workforcePools/$WORKFORCE_POOL_ID/providers/$PROVIDER_ID \ + --subject-token-type=urn:ietf:params:oauth:token-type:id_token \ + --credential-source-file=$PATH_TO_OIDC_ID_TOKEN \ + --workforce-pool-user-project=$WORKFORCE_POOL_USER_PROJECT \ + # Optional arguments for file types. Default is "text": + # --credential-source-type "json" \ + # Optional argument for the field that contains the OIDC credential. + # This is required for json. + # --credential-source-field-name "id_token" \ + --output-file=/path/to/generated/config.json + +Where the following variables need to be substituted: + +* ``$WORKFORCE_POOL_ID``: The workforce pool ID. +* ``$PROVIDER_ID``: The provider ID. +* ``$PATH_TO_OIDC_ID_TOKEN``: The file path used to retrieve the OIDC token. +* ``$WORKFORCE_POOL_USER_PROJECT``: The project number associated with the + `workforce pools user project`_. + +To generate a file-sourced SAML configuration, run the following command: + +.. code-block:: bash + + # Generate a SAML configuration file for file-sourced credentials. + gcloud iam workforce-pools create-cred-config \ + locations/global/workforcePools/$WORKFORCE_POOL_ID/providers/$PROVIDER_ID \ + --credential-source-file=$PATH_TO_SAML_ASSERTION \ + --subject-token-type=urn:ietf:params:oauth:token-type:saml2 \ + --workforce-pool-user-project=$WORKFORCE_POOL_USER_PROJECT \ + --output-file=/path/to/generated/config.json + +Where the following variables need to be substituted: + +* ``$WORKFORCE_POOL_ID``: The workforce pool ID. +* ``$PROVIDER_ID``: The provider ID. +* ``$PATH_TO_SAML_ASSERTION``: The file path used to retrieve the + base64-encoded SAML assertion. +* ``$WORKFORCE_POOL_USER_PROJECT``: The project number associated with the + `workforce pools user project`_. + +These commands generate the configuration file in the specified output file. + +URL-sourced credentials ++++++++++++++++++++++++ + +For URL-sourced credentials, a local server needs to host a GET endpoint to +return the OIDC token. The response can be in plain text or JSON. Additional +required request headers can also be specified. + +To generate a URL-sourced OIDC workforce identity configuration, run the +following command: + +.. code-block:: bash + + # Generate an OIDC configuration file for URL-sourced credentials. + gcloud iam workforce-pools create-cred-config \ + locations/global/workforcePools/$WORKFORCE_POOL_ID/providers/$PROVIDER_ID \ + --subject-token-type=urn:ietf:params:oauth:token-type:id_token \ + --credential-source-url=$URL_TO_RETURN_OIDC_ID_TOKEN \ + --credential-source-headers $HEADER_KEY=$HEADER_VALUE \ + --workforce-pool-user-project=$WORKFORCE_POOL_USER_PROJECT \ + --output-file=/path/to/generated/config.json + +Where the following variables need to be substituted: + +* ``$WORKFORCE_POOL_ID``: The workforce pool ID. +* ``$PROVIDER_ID``: The provider ID. +* ``$URL_TO_RETURN_OIDC_ID_TOKEN``: The URL of the local server endpoint. +* ``$HEADER_KEY`` and ``$HEADER_VALUE``: The additional header key/value + pairs to pass along the GET request to ``$URL_TO_GET_OIDC_TOKEN``, e.g. + ``Metadata-Flavor=Google``. +* ``$WORKFORCE_POOL_USER_PROJECT``: The project number associated with the + `workforce pools user project`_. + +To generate a URL-sourced SAML configuration, run the following command: + +.. code-block:: bash + + # Generate a SAML configuration file for file-sourced credentials. + gcloud iam workforce-pools create-cred-config \ + locations/global/workforcePools/$WORKFORCE_POOL_ID/providers/$PROVIDER_ID \ + --subject-token-type=urn:ietf:params:oauth:token-type:saml2 \ + --credential-source-url=$URL_TO_GET_SAML_ASSERTION \ + --credential-source-headers $HEADER_KEY=$HEADER_VALUE \ + --workforce-pool-user-project=$WORKFORCE_POOL_USER_PROJECT \ + --output-file=/path/to/generated/config.json + +These commands generate the configuration file in the specified output file. + +Where the following variables need to be substituted: + +* ``$WORKFORCE_POOL_ID``: The workforce pool ID. +* ``$PROVIDER_ID``: The provider ID. +* ``$URL_TO_GET_SAML_ASSERTION``: The URL of the local server endpoint. +* ``$HEADER_KEY`` and ``$HEADER_VALUE``: The additional header key/value + pairs to pass along the GET request to ``$URL_TO_GET_SAML_ASSERTION``, e.g. + ``Metadata-Flavor=Google``. +* ``$WORKFORCE_POOL_USER_PROJECT``: The project number associated with the + `workforce pools user project`_. + +Using Executable-sourced workforce credentials with OIDC and SAML +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +**Executable-sourced credentials** For executable-sourced credentials, a local +executable is used to retrieve the 3rd party token. The executable must handle +providing a valid, unexpired OIDC ID token or SAML assertion in JSON format to +stdout. + +To use executable-sourced credentials, the +``GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES`` environment variable must be set +to ``1``. + +To generate an executable-sourced workforce identity configuration, run the +following command: + +.. code-block:: bash + + # Generate a configuration file for executable-sourced credentials. + gcloud iam workforce-pools create-cred-config \ + locations/global/workforcePools/$WORKFORCE_POOL_ID/providers/$PROVIDER_ID \ + --subject-token-type=$SUBJECT_TOKEN_TYPE \ + # The absolute path for the program, including arguments. + # e.g. --executable-command="/path/to/command --foo=bar" + --executable-command=$EXECUTABLE_COMMAND \ + # Optional argument for the executable timeout. Defaults to 30s. + # --executable-timeout-millis=$EXECUTABLE_TIMEOUT \ + # Optional argument for the absolute path to the executable output file. + # See below on how this argument impacts the library behaviour. + # --executable-output-file=$EXECUTABLE_OUTPUT_FILE \ + --workforce-pool-user-project=$WORKFORCE_POOL_USER_PROJECT \ + --output-file /path/to/generated/config.json + +Where the following variables need to be substituted: + +* ``$WORKFORCE_POOL_ID``: The workforce pool ID. +* ``$PROVIDER_ID``: The provider ID. +* ``$SUBJECT_TOKEN_TYPE``: The subject token type. +* ``$EXECUTABLE_COMMAND``: The full command to run, including arguments. Must be + an absolute path to the program. +* ``$WORKFORCE_POOL_USER_PROJECT``: The project number associated with the + workforce pools user project. + +The ``--executable-timeout-millis`` flag is optional. This is the duration for +which the auth library will wait for the executable to finish, in milliseconds. +Defaults to 30 seconds when not provided. The maximum allowed value is 2 +minutes. The minimum is 5 seconds. + +The ``--executable-output-file`` flag is optional. If provided, the file path +must point to the 3rd party credential response generated by the executable. +This is useful for caching the credentials. By specifying this path, the Auth +libraries will first check for its existence before running the executable. By +caching the executable JSON response to this file, it improves performance as it +avoids the need to run the executable until the cached credentials in the output +file are expired. The executable must handle writing to this file - the auth +libraries will only attempt to read from this location. The format of contents +in the file should match the JSON format expected by the executable shown below. + +To retrieve the 3rd party token, the library will call the executable using the +command specified. The executable's output must adhere to the response format +specified below. It must output the response to stdout. + +Refer to the `using executable-sourced credentials with Workload Identity +Federation `__ above +for the executable response specification. + +Accessing resources using a custom supplier with OIDC or SAML +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This library also allows for a custom implementation of :class:`google.auth.identity_pool.SubjectTokenSupplier` +to be specificed when creating a :class:`google.auth.identity_pool.Credential`. The supplier must +return a valid OIDC or SAML2.0 subject token, which will then be exchanged for a +Google Cloud access token. If an error occurs during token retrieval, the supplier +should return a :class:`google.auth.exceptions.RefreshError` and indicate via the error +whether the subject token retrieval is retryable. +Any call to the supplier from the Identity Pool credential will send a :class:`google.auth.external_account.SupplierContext` +object, which contains the requested audience and subject type. Additionally, the credential will +send the :class:`google.auth.transport.requests.Request` passed in the credential refresh call which +can be used to make HTTP requests.:: + + from google.auth import exceptions + from google.auth import identity_pool + + class CustomSubjectTokenSupplier(identity_pool.SubjectTokenSupplier): + + def get_subject_token(self, context, request): + audience = context.audience + subject_token_type = context.subject_token_type + try: + # Attempt to return the valid subject token of the requested type for the requested audience. + except Exception as e: + # If token retrieval fails, raise a refresh error, setting retryable to true if the client should + # attempt to retrieve the subject token again. + raise exceptions.RefreshError(e, retryable=True) + + + supplier = CustomSubjectTokenSupplier() + + credentials = identity_pool.Credentials( + AUDIENCE, # Set GCP Audience. + "urn:ietf:params:aws:token-type:jwt", # Set subject token type. + subject_token_supplier=supplier, # Set supplier. + scopes=SCOPES, # Set desired scopes. + workforce_pool_user_project=USER_PROJECT # Set workforce pool user project. + ) + +Where the audience is: ``//iam.googleapis.com/locations/global/workforcePools/$WORKFORCE_POOL_ID/providers/$PROVIDER_ID`` +Where the following variables need to be substituted: + +* ``$WORKFORCE_POOL_ID``: The workforce pool ID. +* ``$PROVIDER_ID``: The provider ID. + +and the workforce pool user project is the project number associated with the `workforce pools user project`_. + +The values for audience, service account impersonation URL, and any other builder field can also be found +by generating a `credential configuration file`_ with the gcloud CLI. + +.. _workforce pools user project: + https://cloud.google.com/iam/docs/workforce-identity-federation#workforce-pools-user-project +.. _credential configuration file: + https://cloud.google.com/iam/docs/workforce-obtaining-short-lived-credentials#use_configuration_files_for_sign-in + +Security considerations +~~~~~~~~~~~~~~~~~~~~~~~ + +The following security practices are highly recommended: + +* Access to the script should be restricted as it will be displaying credentials + to stdout. This ensures that rogue processes do not gain access to the script. +* The configuration file should not be modifiable. Write access should be + restricted to avoid processes modifying the executable command portion. + +Given the complexity of using executable-sourced credentials, it is recommended +to use the existing supported mechanisms (file-sourced/URL-sourced) for +providing 3rd party credentials unless they do not meet your specific +requirements. + +You can now `use the Auth library <#using-external-identities>`__ to call Google +Cloud resources from an OIDC or SAML provider. + + +.. _Workforce Identity Federation: + https://cloud.google.com/iam/docs/workforce-identity-federation +.. _OpenID Connect (OIDC): https://openid.net/connect/ +.. _instructions: + https://cloud.google.com/iam/docs/configuring-workforce-identity-federation +.. _gcloud CLI: https://cloud.google.com/sdk/ +.. _workforce pools user project: + https://cloud.google.com/iam/docs/workforce-identity-federation#workforce-pools-user-project + + +Note that this library does not perform any validation on the token_url, +token_info_url, or service_account_impersonation_url fields of the credential +configuration. It is not recommended to use a credential configuration that you +did not generate with the gcloud CLI unless you verify that the URL fields point +to a googleapis.com domain. + + +Impersonated credentials +++++++++++++++++++++++++ + +Impersonated Credentials allows one set of credentials issued to a user or service account +to impersonate a service account. Impersonation is the preferred way of using service account for +local development over downloading the service account key. The source credentials must be granted +the "Service Account Token Creator" IAM role. :: + + from google.auth import impersonated_credentials + + target_scopes = ['https://www.googleapis.com/auth/devstorage.read_only'] + source_credentials = service_account.Credentials.from_service_account_file( + '/path/to/svc_account.json', + scopes=target_scopes) + + target_credentials = impersonated_credentials.Credentials( + source_credentials=source_credentials, + target_principal='impersonated-account@_project_.iam.gserviceaccount.com', + target_scopes=target_scopes, + lifetime=500) + client = storage.Client(credentials=target_credentials) + buckets = client.list_buckets(project='your_project') + for bucket in buckets: + print(bucket.name) + + +In the example above `source_credentials` does not have direct access to list buckets +in the target project. Using `ImpersonatedCredentials` will allow the source_credentials +to assume the identity of a target_principal that does have access. + +It is possible to provide a delegation chain through `delegates` paramter while +initializing the impersonated credential. Refer `create short lived credentials delegated`_ for more details on delegation chain. + +.. _create short lived credentials delegated: https://cloud.google.com/iam/docs/create-short-lived-credentials-delegated + + +Service account private key files ++++++++++++++++++++++++++++++++++ + +A service account private key file can be used to obtain credentials for a service account. If you are not +able to use any of the authentication methods listed above, you can create a private key using `Credentials page of the +Google Cloud Console`_. Once you have a private key you can obtain +credentials one of three ways: + +1. Set the ``GOOGLE_APPLICATION_CREDENTIALS`` environment variable to the full + path to your service account private key file + + .. code-block:: bash + + $ export GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json + + Then, use :ref:`application default credentials `. + :func:`default` checks for the ``GOOGLE_APPLICATION_CREDENTIALS`` + environment variable before all other checks, so this will always use the + credentials you explicitly specify. + +2. Use :meth:`service_account.Credentials.from_service_account_file + `:: + + from google.oauth2 import service_account + + credentials = service_account.Credentials.from_service_account_file( + '/path/to/key.json') + + scoped_credentials = credentials.with_scopes( + ['https://www.googleapis.com/auth/cloud-platform']) + +3. Use :meth:`service_account.Credentials.from_service_account_info + `:: + + import json + + from google.oauth2 import service_account + + json_acct_info = json.loads(function_to_get_json_creds()) + credentials = service_account.Credentials.from_service_account_info( + json_acct_info) + + scoped_credentials = credentials.with_scopes( + ['https://www.googleapis.com/auth/cloud-platform']) + +.. warning:: Private keys must be kept secret. If you expose your private key it + is recommended to revoke it immediately from the Google Cloud Console. + +.. _Credentials page of the Google Cloud Console: + https://console.cloud.google.com/apis/credentials + + +Downscoped credentials +++++++++++++++++++++++ + +`Downscoping with Credential Access Boundaries`_ is used to restrict the +Identity and Access Management (IAM) permissions that a short-lived credential +can use. + +To downscope permissions of a source credential, a `Credential Access Boundary` +that specifies which resources the new credential can access, as well as +an upper bound on the permissions that are available on each resource, has to +be defined. A downscoped credential can then be instantiated using the +`source_credential` and the `Credential Access Boundary`. + +The common pattern of usage is to have a token broker with elevated access +generate these downscoped credentials from higher access source credentials and +pass the downscoped short-lived access tokens to a token consumer via some +secure authenticated channel for limited access to Google Cloud Storage +resources. + +.. _Downscoping with Credential Access Boundaries: https://cloud.google.com/iam/docs/downscoping-short-lived-credentials + +Token broker :: + + import google.auth + + from google.auth import downscoped + from google.auth.transport import requests + + # Initialize the credential access boundary rules. + available_resource = '//storage.googleapis.com/projects/_/buckets/bucket-123' + available_permissions = ['inRole:roles/storage.objectViewer'] + availability_expression = ( + "resource.name.startsWith('projects/_/buckets/bucket-123/objects/customer-a')" + ) + + availability_condition = downscoped.AvailabilityCondition( + availability_expression) + rule = downscoped.AccessBoundaryRule( + available_resource=available_resource, + available_permissions=available_permissions, + availability_condition=availability_condition) + credential_access_boundary = downscoped.CredentialAccessBoundary( + rules=[rule]) + + # Retrieve the source credentials via ADC. + source_credentials, _ = google.auth.default() + + # Create the downscoped credentials. + downscoped_credentials = downscoped.Credentials( + source_credentials=source_credentials, + credential_access_boundary=credential_access_boundary) + + # Refresh the tokens. + downscoped_credentials.refresh(requests.Request()) + + # These values will need to be passed to the Token Consumer. + access_token = downscoped_credentials.token + expiry = downscoped_credentials.expiry + + +For example, a token broker can be set up on a server in a private network. +Various workloads (token consumers) in the same network will send authenticated +requests to that broker for downscoped tokens to access or modify specific google +cloud storage buckets. + +The broker will instantiate downscoped credentials instances that can be used to +generate short lived downscoped access tokens that can be passed to the token +consumer. These downscoped access tokens can be injected by the consumer into +`google.oauth2.Credentials` and used to initialize a storage client instance to +access Google Cloud Storage resources with restricted access. + +Token Consumer :: + + import google.oauth2 + + from google.auth.transport import requests + from google.cloud import storage + + # Downscoped token retrieved from token broker. + # The `get_token_from_broker` callable requests a token and an expiry + # from the token broker. + downscoped_token, expiry = get_token_from_broker( + requests.Request(), + scopes=['https://www.googleapis.com/auth/cloud-platform']) + + # Create the OAuth credentials from the downscoped token and pass a + # refresh handler to handle token expiration. Passing the original + # downscoped token or the expiry here is optional, as the refresh_handler + # will generate the downscoped token on demand. + credentials = google.oauth2.credentials.Credentials( + downscoped_token, + expiry=expiry, + scopes=['https://www.googleapis.com/auth/cloud-platform'], + refresh_handler=get_token_from_broker) + + # Initialize a storage client with the oauth2 credentials. + storage_client = storage.Client( + project='my_project_id', credentials=credentials) + # Call GCS APIs. + # The token broker has readonly access to objects starting with "customer-a" + # in bucket "bucket-123". + bucket = storage_client.bucket('bucket-123') + blob = bucket.blob('customer-a-data.txt') + print(blob.download_as_bytes().decode("utf-8")) + + +Another reason to use downscoped credentials is to ensure tokens in flight +always have the least privileges, e.g. Principle of Least Privilege. :: + + # Create the downscoped credentials. + downscoped_credentials = downscoped.Credentials( + # source_credentials have elevated access but only a subset of + # these permissions are needed here. + source_credentials=source_credentials, + credential_access_boundary=credential_access_boundary) + + # Pass the token directly. + storage_client = storage.Client( + project='my_project_id', credentials=downscoped_credentials) + # If the source credentials have elevated levels of access, the + # token in flight here will have limited readonly access to objects + # starting with "customer-a" in bucket "bucket-123". + bucket = storage_client.bucket('bucket-123') + blob = bucket.blob('customer-a-data.txt') + print(blob.download_as_string()) + + +Note: Only Cloud Storage supports Credential Access Boundaries. Other Google +Cloud services do not support this feature. + + +Identity Tokens ++++++++++++++++ + +`Google OpenID Connect`_ tokens are available through :mod:`Service Account `, +:mod:`Impersonated `, +and :mod:`Compute Engine `. These tokens can be used to +authenticate against `Cloud Functions`_, `Cloud Run`_, a user service behind +`Identity Aware Proxy`_ or any other service capable of verifying a `Google ID Token`_. + +ServiceAccount :: + + from google.oauth2 import service_account + + target_audience = 'https://example.com' + + creds = service_account.IDTokenCredentials.from_service_account_file( + '/path/to/svc.json', + target_audience=target_audience) + + +Compute :: + + from google.auth import compute_engine + import google.auth.transport.requests + + target_audience = 'https://example.com' + + request = google.auth.transport.requests.Request() + creds = compute_engine.IDTokenCredentials(request, + target_audience=target_audience) + +Impersonated :: + + from google.auth import impersonated_credentials + + # get target_credentials from a source_credential + + target_audience = 'https://example.com' + + creds = impersonated_credentials.IDTokenCredentials( + target_credentials, + target_audience=target_audience) + +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. It supports ID token signed with RS256 +and ES256 algorithms. However, ES256 algorithm won't be available unless +`cryptography` dependency of version at least 1.4.0 is installed. You can check +the dependency with `pip freeze` or try `from google.auth.crypt import es256`. +The following is an example of verifying ID tokens :: + + from google.auth2 import id_token + + request = google.auth.transport.requests.Request() + + try: + decoded_token = id_token.verify_token(token_to_verify,request) + except ValueError: + # Verification failed. + +A sample end-to-end flow using an ID Token against a Cloud Run endpoint maybe :: + + from google.oauth2 import id_token + from google.oauth2 import service_account + import google.auth + import google.auth.transport.requests + from google.auth.transport.requests import AuthorizedSession + + target_audience = 'https://your-cloud-run-app.a.run.app' + url = 'https://your-cloud-run-app.a.run.app' + + creds = service_account.IDTokenCredentials.from_service_account_file( + '/path/to/svc.json', target_audience=target_audience) + + authed_session = AuthorizedSession(creds) + + # make authenticated request and print the response, status_code + resp = authed_session.get(url) + print(resp.status_code) + print(resp.text) + + # to verify an ID Token + request = google.auth.transport.requests.Request() + token = creds.token + 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/ +.. _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 + +Making authenticated requests +----------------------------- + +Once you have credentials you can attach them to a *transport*. You can then +use this transport to make authenticated requests to APIs. google-auth supports +several different transports. Typically, it's up to your application or an +opinionated client library to decide which transport to use. + +Requests +++++++++ + +The recommended HTTP transport is :mod:`google.auth.transport.requests` which +uses the `Requests`_ library. To make authenticated requests using Requests +you use a custom `Session`_ object:: + + from google.auth.transport.requests import AuthorizedSession + + authed_session = AuthorizedSession(credentials) + + response = authed_session.get( + 'https://www.googleapis.com/storage/v1/b') + +.. _Requests: http://docs.python-requests.org/en/master/ +.. _Session: http://docs.python-requests.org/en/master/user/advanced/#session-objects + +urllib3 ++++++++ + +:mod:`urllib3` is the underlying HTTP library used by Requests and can also be +used with google-auth. urllib3's interface isn't as high-level as Requests but +it can be useful in situations where you need more control over how HTTP +requests are made. To make authenticated requests using urllib3 create an +instance of :class:`google.auth.transport.urllib3.AuthorizedHttp`:: + + from google.auth.transport.urllib3 import AuthorizedHttp + + authed_http = AuthorizedHttp(credentials) + + response = authed_http.request( + 'GET', 'https://www.googleapis.com/storage/v1/b') + +You can also construct your own :class:`urllib3.PoolManager` instance and pass +it to :class:`~google.auth.transport.urllib3.AuthorizedHttp`:: + + import urllib3 + + http = urllib3.PoolManager() + authed_http = AuthorizedHttp(credentials, http) + +gRPC +++++ + +`gRPC`_ is an RPC framework that uses `Protocol Buffers`_ over `HTTP 2.0`_. +google-auth can provide `Call Credentials`_ for gRPC. The easiest way to do +this is to use google-auth to create the gRPC channel:: + + import google.auth.transport.grpc + import google.auth.transport.requests + + http_request = google.auth.transport.requests.Request() + + channel = google.auth.transport.grpc.secure_authorized_channel( + credentials, http_request, 'pubsub.googleapis.com:443') + +.. note:: Even though gRPC is its own transport, you still need to use one of + the other HTTP transports with gRPC. The reason is that most credential + types need to make HTTP requests in order to refresh their access token. + The sample above uses the Requests transport, but any HTTP transport can + be used. Additionally, if you know that your credentials do not need to + make HTTP requests in order to refresh (as is the case with + :class:`jwt.Credentials`) then you can specify ``None``. + +Alternatively, you can create the channel yourself and use +:class:`google.auth.transport.grpc.AuthMetadataPlugin`:: + + import grpc + + metadata_plugin = AuthMetadataPlugin(credentials, http_request) + + # Create a set of grpc.CallCredentials using the metadata plugin. + google_auth_credentials = grpc.metadata_call_credentials( + metadata_plugin) + + # Create SSL channel credentials. + ssl_credentials = grpc.ssl_channel_credentials() + + # Combine the ssl credentials and the authorization credentials. + composite_credentials = grpc.composite_channel_credentials( + ssl_credentials, google_auth_credentials) + + channel = grpc.secure_channel( + 'pubsub.googleapis.com:443', composite_credentials) + +You can use this channel to make a gRPC stub that makes authenticated requests +to a gRPC service:: + + from google.pubsub.v1 import pubsub_pb2 + + pubsub = pubsub_pb2.PublisherStub(channel) + + response = pubsub.ListTopics( + pubsub_pb2.ListTopicsRequest(project='your-project')) + + +.. _gRPC: http://www.grpc.io/ +.. _Protocol Buffers: + https://developers.google.com/protocol-buffers/docs/overview +.. _HTTP 2.0: + http://www.grpc.io/docs/guides/wire.html +.. _Call Credentials: + http://www.grpc.io/docs/guides/auth.html diff --git a/packages/google-auth/google/auth/__init__.py b/packages/google-auth/google/auth/__init__.py new file mode 100644 index 000000000000..765bbd70581a --- /dev/null +++ b/packages/google-auth/google/auth/__init__.py @@ -0,0 +1,53 @@ +# Copyright 2016 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. + +"""Google Auth Library for Python.""" + +import logging +import sys +import warnings + +from google.auth import version as google_auth_version +from google.auth._default import ( + default, + load_credentials_from_dict, + load_credentials_from_file, +) + + +__version__ = google_auth_version.__version__ + + +__all__ = ["default", "load_credentials_from_file", "load_credentials_from_dict"] + + +class Python37DeprecationWarning(DeprecationWarning): # pragma: NO COVER + """ + Deprecation warning raised when Python 3.7 runtime is detected. + Python 3.7 support will be dropped after January 1, 2024. + """ + + pass + + +# Checks if the current runtime is Python 3.7. +if sys.version_info.major == 3 and sys.version_info.minor == 7: # pragma: NO COVER + message = ( + "After January 1, 2024, new releases of this library will drop support " + "for Python 3.7." + ) + warnings.warn(message, Python37DeprecationWarning) + +# Set default logging handler to avoid "No handler found" warnings. +logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/packages/google-auth/google/auth/_cloud_sdk.py b/packages/google-auth/google/auth/_cloud_sdk.py new file mode 100644 index 000000000000..85b3c4f99be3 --- /dev/null +++ b/packages/google-auth/google/auth/_cloud_sdk.py @@ -0,0 +1,153 @@ +# Copyright 2015 Google Inc. +# +# 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. + +"""Helpers for reading the Google Cloud SDK's configuration.""" + +import os +import subprocess + +from google.auth import _helpers +from google.auth import environment_vars +from google.auth import exceptions + + +# The ~/.config subdirectory containing gcloud credentials. +_CONFIG_DIRECTORY = "gcloud" +# Windows systems store config at %APPDATA%\gcloud +_WINDOWS_CONFIG_ROOT_ENV_VAR = "APPDATA" +# The name of the file in the Cloud SDK config that contains default +# credentials. +_CREDENTIALS_FILENAME = "application_default_credentials.json" +# The name of the Cloud SDK shell script +_CLOUD_SDK_POSIX_COMMAND = "gcloud" +_CLOUD_SDK_WINDOWS_COMMAND = "gcloud.cmd" +# The command to get the Cloud SDK configuration +_CLOUD_SDK_CONFIG_GET_PROJECT_COMMAND = ("config", "get", "project") +# The command to get google user access token +_CLOUD_SDK_USER_ACCESS_TOKEN_COMMAND = ("auth", "print-access-token") +# Cloud SDK's application-default client ID +CLOUD_SDK_CLIENT_ID = ( + "764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com" +) + + +def get_config_path(): + """Returns the absolute path the the Cloud SDK's configuration directory. + + Returns: + str: The Cloud SDK config path. + """ + # If the path is explicitly set, return that. + try: + return os.environ[environment_vars.CLOUD_SDK_CONFIG_DIR] + except KeyError: + pass + + # Non-windows systems store this at ~/.config/gcloud + if os.name != "nt": + return os.path.join(os.path.expanduser("~"), ".config", _CONFIG_DIRECTORY) + # Windows systems store config at %APPDATA%\gcloud + else: + try: + return os.path.join( + os.environ[_WINDOWS_CONFIG_ROOT_ENV_VAR], _CONFIG_DIRECTORY + ) + except KeyError: + # This should never happen unless someone is really + # messing with things, but we'll cover the case anyway. + drive = os.environ.get("SystemDrive", "C:") + return os.path.join(drive, "\\", _CONFIG_DIRECTORY) + + +def get_application_default_credentials_path(): + """Gets the path to the application default credentials file. + + The path may or may not exist. + + Returns: + str: The full path to application default credentials. + """ + config_path = get_config_path() + return os.path.join(config_path, _CREDENTIALS_FILENAME) + + +def _run_subprocess_ignore_stderr(command): + """Return subprocess.check_output with the given command and ignores stderr.""" + with open(os.devnull, "w") as devnull: + output = subprocess.check_output(command, stderr=devnull) + return output + + +def get_project_id(): + """Gets the project ID from the Cloud SDK. + + Returns: + Optional[str]: The project ID. + """ + if os.name == "nt": + command = _CLOUD_SDK_WINDOWS_COMMAND + else: + command = _CLOUD_SDK_POSIX_COMMAND + + try: + # Ignore the stderr coming from gcloud, so it won't be mixed into the output. + # https://github.com/googleapis/google-auth-library-python/issues/673 + project = _run_subprocess_ignore_stderr( + (command,) + _CLOUD_SDK_CONFIG_GET_PROJECT_COMMAND + ) + + # Turn bytes into a string and remove "\n" + project = _helpers.from_bytes(project).strip() + return project if project else None + except (subprocess.CalledProcessError, OSError, IOError): + return None + + +def get_auth_access_token(account=None): + """Load user access token with the ``gcloud auth print-access-token`` command. + + Args: + account (Optional[str]): Account to get the access token for. If not + specified, the current active account will be used. + + Returns: + str: The user access token. + + Raises: + google.auth.exceptions.UserAccessTokenError: if failed to get access + token from gcloud. + """ + if os.name == "nt": + command = _CLOUD_SDK_WINDOWS_COMMAND + else: + command = _CLOUD_SDK_POSIX_COMMAND + + try: + if account: + command = ( + (command,) + + _CLOUD_SDK_USER_ACCESS_TOKEN_COMMAND + + ("--account=" + account,) + ) + else: + command = (command,) + _CLOUD_SDK_USER_ACCESS_TOKEN_COMMAND + + access_token = subprocess.check_output(command, stderr=subprocess.STDOUT) + # remove the trailing "\n" + return access_token.decode("utf-8").strip() + except (subprocess.CalledProcessError, OSError, IOError) as caught_exc: + new_exc = exceptions.UserAccessTokenError( + "Failed to obtain access token", caught_exc + ) + raise new_exc from caught_exc diff --git a/packages/google-auth/google/auth/_constants.py b/packages/google-auth/google/auth/_constants.py new file mode 100644 index 000000000000..28e47025fc4d --- /dev/null +++ b/packages/google-auth/google/auth/_constants.py @@ -0,0 +1,5 @@ +"""Shared constants.""" + +_SERVICE_ACCOUNT_TRUST_BOUNDARY_LOOKUP_ENDPOINT = "https://iamcredentials.{universe_domain}/v1/projects/-/serviceAccounts/{service_account_email}/allowedLocations" +_WORKFORCE_POOL_TRUST_BOUNDARY_LOOKUP_ENDPOINT = "https://iamcredentials.{universe_domain}/v1/locations/global/workforcePools/{pool_id}/allowedLocations" +_WORKLOAD_IDENTITY_POOL_TRUST_BOUNDARY_LOOKUP_ENDPOINT = "https://iamcredentials.{universe_domain}/v1/projects/{project_number}/locations/global/workloadIdentityPools/{pool_id}/allowedLocations" diff --git a/packages/google-auth/google/auth/_credentials_async.py b/packages/google-auth/google/auth/_credentials_async.py new file mode 100644 index 000000000000..760758d851b0 --- /dev/null +++ b/packages/google-auth/google/auth/_credentials_async.py @@ -0,0 +1,171 @@ +# 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. + + +"""Interfaces for credentials.""" + +import abc +import inspect + +from google.auth import credentials + + +class Credentials(credentials.Credentials, metaclass=abc.ABCMeta): + """Async inherited credentials class from google.auth.credentials. + The added functionality is the before_request call which requires + async/await syntax. + All credentials have a :attr:`token` that is used for authentication and + may also optionally set an :attr:`expiry` to indicate when the token will + no longer be valid. + + Most credentials will be :attr:`invalid` until :meth:`refresh` is called. + Credentials can do this automatically before the first HTTP request in + :meth:`before_request`. + + Although the token and expiration will change as the credentials are + :meth:`refreshed ` and used, credentials should be considered + immutable. Various credentials will accept configuration such as private + keys, scopes, and other options. These options are not changeable after + construction. Some classes will provide mechanisms to copy the credentials + with modifications such as :meth:`ScopedCredentials.with_scopes`. + """ + + async def before_request(self, request, method, url, headers): + """Performs credential-specific before request logic. + + Refreshes the credentials if necessary, then calls :meth:`apply` to + apply the token to the authentication header. + + Args: + request (google.auth.transport.Request): The object used to make + HTTP requests. + method (str): The request's HTTP method or the RPC method being + invoked. + url (str): The request's URI or the RPC service's URI. + headers (Mapping): The request's headers. + """ + # pylint: disable=unused-argument + # (Subclasses may use these arguments to ascertain information about + # the http request.) + + if not self.valid: + if inspect.iscoroutinefunction(self.refresh): + await self.refresh(request) + else: + self.refresh(request) + self.apply(headers) + + +class CredentialsWithQuotaProject(credentials.CredentialsWithQuotaProject): + """Abstract base for credentials supporting ``with_quota_project`` factory""" + + +class AnonymousCredentials(credentials.AnonymousCredentials, Credentials): + """Credentials that do not provide any authentication information. + + These are useful in the case of services that support anonymous access or + local service emulators that do not use credentials. This class inherits + from the sync anonymous credentials file, but is kept if async credentials + is initialized and we would like anonymous credentials. + """ + + +class ReadOnlyScoped(credentials.ReadOnlyScoped, metaclass=abc.ABCMeta): + """Interface for credentials whose scopes can be queried. + + OAuth 2.0-based credentials allow limiting access using scopes as described + in `RFC6749 Section 3.3`_. + If a credential class implements this interface then the credentials either + use scopes in their implementation. + + Some credentials require scopes in order to obtain a token. You can check + if scoping is necessary with :attr:`requires_scopes`:: + + if credentials.requires_scopes: + # Scoping is required. + credentials = _credentials_async.with_scopes(scopes=['one', 'two']) + + Credentials that require scopes must either be constructed with scopes:: + + credentials = SomeScopedCredentials(scopes=['one', 'two']) + + Or must copy an existing instance using :meth:`with_scopes`:: + + scoped_credentials = _credentials_async.with_scopes(scopes=['one', 'two']) + + Some credentials have scopes but do not allow or require scopes to be set, + these credentials can be used as-is. + + .. _RFC6749 Section 3.3: https://tools.ietf.org/html/rfc6749#section-3.3 + """ + + +class Scoped(credentials.Scoped): + """Interface for credentials whose scopes can be replaced while copying. + + OAuth 2.0-based credentials allow limiting access using scopes as described + in `RFC6749 Section 3.3`_. + If a credential class implements this interface then the credentials either + use scopes in their implementation. + + Some credentials require scopes in order to obtain a token. You can check + if scoping is necessary with :attr:`requires_scopes`:: + + if credentials.requires_scopes: + # Scoping is required. + credentials = _credentials_async.create_scoped(['one', 'two']) + + Credentials that require scopes must either be constructed with scopes:: + + credentials = SomeScopedCredentials(scopes=['one', 'two']) + + Or must copy an existing instance using :meth:`with_scopes`:: + + scoped_credentials = credentials.with_scopes(scopes=['one', 'two']) + + Some credentials have scopes but do not allow or require scopes to be set, + these credentials can be used as-is. + + .. _RFC6749 Section 3.3: https://tools.ietf.org/html/rfc6749#section-3.3 + """ + + +def with_scopes_if_required(credentials, scopes): + """Creates a copy of the credentials with scopes if scoping is required. + + This helper function is useful when you do not know (or care to know) the + specific type of credentials you are using (such as when you use + :func:`google.auth.default`). This function will call + :meth:`Scoped.with_scopes` if the credentials are scoped credentials and if + the credentials require scoping. Otherwise, it will return the credentials + as-is. + + Args: + credentials (google.auth.credentials.Credentials): The credentials to + scope if necessary. + scopes (Sequence[str]): The list of scopes to use. + + Returns: + google.auth._credentials_async.Credentials: Either a new set of scoped + credentials, or the passed in credentials instance if no scoping + was required. + """ + if isinstance(credentials, Scoped) and credentials.requires_scopes: + return credentials.with_scopes(scopes) + else: + return credentials + + +class Signing(credentials.Signing, metaclass=abc.ABCMeta): + """Interface for credentials that can cryptographically sign messages.""" diff --git a/packages/google-auth/google/auth/_credentials_base.py b/packages/google-auth/google/auth/_credentials_base.py new file mode 100644 index 000000000000..64d5ce34b9a3 --- /dev/null +++ b/packages/google-auth/google/auth/_credentials_base.py @@ -0,0 +1,75 @@ +# Copyright 2024 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. + + +"""Interface for base credentials.""" + +import abc + +from google.auth import _helpers + + +class _BaseCredentials(metaclass=abc.ABCMeta): + """Base class for all credentials. + + All credentials have a :attr:`token` that is used for authentication and + may also optionally set an :attr:`expiry` to indicate when the token will + no longer be valid. + + Most credentials will be :attr:`invalid` until :meth:`refresh` is called. + Credentials can do this automatically before the first HTTP request in + :meth:`before_request`. + + Although the token and expiration will change as the credentials are + :meth:`refreshed ` and used, credentials should be considered + immutable. Various credentials will accept configuration such as private + keys, scopes, and other options. These options are not changeable after + construction. Some classes will provide mechanisms to copy the credentials + with modifications such as :meth:`ScopedCredentials.with_scopes`. + + Attributes: + token (Optional[str]): The bearer token that can be used in HTTP headers to make + authenticated requests. + """ + + def __init__(self): + self.token = None + + @abc.abstractmethod + def refresh(self, request): + """Refreshes the access token. + + Args: + request (google.auth.transport.Request): The object used to make + HTTP requests. + + Raises: + google.auth.exceptions.RefreshError: If the credentials could + not be refreshed. + """ + # pylint: disable=missing-raises-doc + # (pylint doesn't recognize that this is abstract) + raise NotImplementedError("Refresh must be implemented") + + def _apply(self, headers, token=None): + """Apply the token to the authentication header. + + Args: + headers (Mapping): The HTTP request headers. + token (Optional[str]): If specified, overrides the current access + token. + """ + headers["authorization"] = "Bearer {}".format( + _helpers.from_bytes(token or self.token) + ) diff --git a/packages/google-auth/google/auth/_default.py b/packages/google-auth/google/auth/_default.py new file mode 100644 index 000000000000..8046b4c73d3d --- /dev/null +++ b/packages/google-auth/google/auth/_default.py @@ -0,0 +1,741 @@ +# Copyright 2015 Google Inc. +# +# 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. + +"""Application default credentials. + +Implements application default credentials and project ID detection. +""" + +import io +import json +import logging +import os +import warnings + +from google.auth import environment_vars +from google.auth import exceptions +import google.auth.transport._http_client + +_LOGGER = logging.getLogger(__name__) + +# Valid types accepted for file-based credentials. +_AUTHORIZED_USER_TYPE = "authorized_user" +_SERVICE_ACCOUNT_TYPE = "service_account" +_EXTERNAL_ACCOUNT_TYPE = "external_account" +_EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE = "external_account_authorized_user" +_IMPERSONATED_SERVICE_ACCOUNT_TYPE = "impersonated_service_account" +_GDCH_SERVICE_ACCOUNT_TYPE = "gdch_service_account" +_VALID_TYPES = ( + _AUTHORIZED_USER_TYPE, + _SERVICE_ACCOUNT_TYPE, + _EXTERNAL_ACCOUNT_TYPE, + _EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE, + _IMPERSONATED_SERVICE_ACCOUNT_TYPE, + _GDCH_SERVICE_ACCOUNT_TYPE, +) + +# Help message when no credentials can be found. +_CLOUD_SDK_MISSING_CREDENTIALS = """\ +Your default credentials were not found. To set up Application Default Credentials, \ +see https://cloud.google.com/docs/authentication/external/set-up-adc for more information.\ +""" + +# Warning when using Cloud SDK user credentials +_CLOUD_SDK_CREDENTIALS_WARNING = """\ +Your application has authenticated using end user credentials from Google \ +Cloud SDK without a quota project. You might receive a "quota exceeded" \ +or "API not enabled" error. See the following page for troubleshooting: \ +https://cloud.google.com/docs/authentication/adc-troubleshooting/user-creds. \ +""" + +_GENERIC_LOAD_METHOD_WARNING = """\ +The {} method is deprecated because of a potential security risk. + +This method does not validate the credential configuration. The security +risk occurs when a credential configuration is accepted from a source that +is not under your control and used without validation on your side. + +If you know that you will be loading credential configurations of a +specific type, it is recommended to use a credential-type-specific +load method. +This will ensure that an unexpected credential type with potential for +malicious intent is not loaded unintentionally. You might still have to do +validation for certain credential types. Please follow the recommendations +for that method. For example, if you want to load only service accounts, +you can create the service account credentials explicitly: + +``` +from google.oauth2 import service_account +creds = service_account.Credentials.from_service_account_file(filename) +``` + +If you are loading your credential configuration from an untrusted source and have +not mitigated the risks (e.g. by validating the configuration yourself), make +these changes as soon as possible to prevent security risks to your environment. + +Regardless of the method used, it is always your responsibility to validate +configurations received from external sources. + +Refer to https://cloud.google.com/docs/authentication/external/externally-sourced-credentials +for more details. +""" + +# The subject token type used for AWS external_account credentials. +_AWS_SUBJECT_TOKEN_TYPE = "urn:ietf:params:aws:token-type:aws4_request" + + +def _warn_about_problematic_credentials(credentials): + """Determines if the credentials are problematic. + + Credentials from the Cloud SDK that are associated with Cloud SDK's project + are problematic because they may not have APIs enabled and have limited + quota. If this is the case, warn about it. + """ + from google.auth import _cloud_sdk + + if credentials.client_id == _cloud_sdk.CLOUD_SDK_CLIENT_ID: + warnings.warn(_CLOUD_SDK_CREDENTIALS_WARNING) + + +def _warn_about_generic_load_method(method_name): # pragma: NO COVER + """Warns that a generic load method is being used. + + This is to discourage use of the generic load methods in favor of + more specific methods. The generic methods are more likely to lead to + security issues if the input is not validated. + + Args: + method_name (str): The name of the method being used. + """ + + warnings.warn(_GENERIC_LOAD_METHOD_WARNING.format(method_name), DeprecationWarning) + + +def load_credentials_from_file( + filename, scopes=None, default_scopes=None, quota_project_id=None, request=None +): + """Loads Google credentials from a file. + + The credentials file must be a service account key, stored authorized + user credentials, external account credentials, or impersonated service + account credentials. + + .. warning:: + Important: If you accept a credential configuration (credential JSON/File/Stream) + from an external source for authentication to Google Cloud Platform, you must + validate it before providing it to any Google API or client library. Providing an + unvalidated credential configuration to Google APIs or libraries can compromise + the security of your systems and data. For more information, refer to + `Validate credential configurations from external sources`_. + + .. _Validate credential configurations from external sources: + https://cloud.google.com/docs/authentication/external/externally-sourced-credentials + + Args: + filename (str): The full path to the credentials file. + scopes (Optional[Sequence[str]]): The list of scopes for the credentials. If + specified, the credentials will automatically be scoped if + necessary + default_scopes (Optional[Sequence[str]]): Default scopes passed by a + Google client library. Use 'scopes' for user-defined scopes. + quota_project_id (Optional[str]): The project ID used for + quota and billing. + request (Optional[google.auth.transport.Request]): An object used to make + HTTP requests. This is used to determine the associated project ID + for a workload identity pool resource (external account credentials). + If not specified, then it will use a + google.auth.transport.requests.Request client to make requests. + + Returns: + Tuple[google.auth.credentials.Credentials, Optional[str]]: Loaded + credentials and the project ID. Authorized user credentials do not + have the project ID information. External account credentials project + IDs may not always be determined. + + Raises: + google.auth.exceptions.DefaultCredentialsError: if the file is in the + wrong format or is missing. + """ + _warn_about_generic_load_method("load_credentials_from_file") + + if not os.path.exists(filename): + raise exceptions.DefaultCredentialsError( + "File {} was not found.".format(filename) + ) + + with io.open(filename, "r") as file_obj: + try: + info = json.load(file_obj) + except ValueError as caught_exc: + new_exc = exceptions.DefaultCredentialsError( + "File {} is not a valid json file.".format(filename), caught_exc + ) + raise new_exc from caught_exc + return _load_credentials_from_info( + filename, info, scopes, default_scopes, quota_project_id, request + ) + + +def load_credentials_from_dict( + info, scopes=None, default_scopes=None, quota_project_id=None, request=None +): + """Loads Google credentials from a dict. + + The credentials file must be a service account key, stored authorized + user credentials, external account credentials, or impersonated service + account credentials. + + .. warning:: + Important: If you accept a credential configuration (credential JSON/File/Stream) + from an external source for authentication to Google Cloud Platform, you must + validate it before providing it to any Google API or client library. Providing an + unvalidated credential configuration to Google APIs or libraries can compromise + the security of your systems and data. For more information, refer to + `Validate credential configurations from external sources`_. + + .. _Validate credential configurations from external sources: + https://cloud.google.com/docs/authentication/external/externally-sourced-credentials + + Args: + info (Dict[str, Any]): A dict object containing the credentials + scopes (Optional[Sequence[str]]): The list of scopes for the credentials. If + specified, the credentials will automatically be scoped if + necessary + default_scopes (Optional[Sequence[str]]): Default scopes passed by a + Google client library. Use 'scopes' for user-defined scopes. + quota_project_id (Optional[str]): The project ID used for + quota and billing. + request (Optional[google.auth.transport.Request]): An object used to make + HTTP requests. This is used to determine the associated project ID + for a workload identity pool resource (external account credentials). + If not specified, then it will use a + google.auth.transport.requests.Request client to make requests. + + Returns: + Tuple[google.auth.credentials.Credentials, Optional[str]]: Loaded + credentials and the project ID. Authorized user credentials do not + have the project ID information. External account credentials project + IDs may not always be determined. + + Raises: + google.auth.exceptions.DefaultCredentialsError: if the file is in the + wrong format or is missing. + """ + _warn_about_generic_load_method("load_credentials_from_dict") + if not isinstance(info, dict): + raise exceptions.DefaultCredentialsError( + "info object was of type {} but dict type was expected.".format(type(info)) + ) + + return _load_credentials_from_info( + "dict object", info, scopes, default_scopes, quota_project_id, request + ) + + +def _load_credentials_from_info( + filename, info, scopes, default_scopes, quota_project_id, request +): + from google.auth.credentials import CredentialsWithQuotaProject + + credential_type = info.get("type") + + if credential_type == _AUTHORIZED_USER_TYPE: + credentials, project_id = _get_authorized_user_credentials( + filename, info, scopes + ) + + elif credential_type == _SERVICE_ACCOUNT_TYPE: + credentials, project_id = _get_service_account_credentials( + filename, info, scopes, default_scopes + ) + + elif credential_type == _EXTERNAL_ACCOUNT_TYPE: + credentials, project_id = _get_external_account_credentials( + info, + filename, + scopes=scopes, + default_scopes=default_scopes, + request=request, + ) + + elif credential_type == _EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE: + credentials, project_id = _get_external_account_authorized_user_credentials( + filename, info, request + ) + + elif credential_type == _IMPERSONATED_SERVICE_ACCOUNT_TYPE: + credentials, project_id = _get_impersonated_service_account_credentials( + filename, info, scopes + ) + elif credential_type == _GDCH_SERVICE_ACCOUNT_TYPE: + credentials, project_id = _get_gdch_service_account_credentials(filename, info) + else: + raise exceptions.DefaultCredentialsError( + "The file {file} does not have a valid type. " + "Type is {type}, expected one of {valid_types}.".format( + file=filename, type=credential_type, valid_types=_VALID_TYPES + ) + ) + if isinstance(credentials, CredentialsWithQuotaProject): + credentials = _apply_quota_project_id(credentials, quota_project_id) + return credentials, project_id + + +def _get_gcloud_sdk_credentials(quota_project_id=None): + """Gets the credentials and project ID from the Cloud SDK.""" + from google.auth import _cloud_sdk + + _LOGGER.debug("Checking Cloud SDK credentials as part of auth process...") + + # Check if application default credentials exist. + credentials_filename = _cloud_sdk.get_application_default_credentials_path() + + if not os.path.isfile(credentials_filename): + _LOGGER.debug("Cloud SDK credentials not found on disk; not using them") + return None, None + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + credentials, project_id = load_credentials_from_file( + credentials_filename, quota_project_id=quota_project_id + ) + credentials._cred_file_path = credentials_filename + + if not project_id: + project_id = _cloud_sdk.get_project_id() + + return credentials, project_id + + +def _get_explicit_environ_credentials(quota_project_id=None): + """Gets credentials from the GOOGLE_APPLICATION_CREDENTIALS environment + variable.""" + from google.auth import _cloud_sdk + + cloud_sdk_adc_path = _cloud_sdk.get_application_default_credentials_path() + explicit_file = os.environ.get(environment_vars.CREDENTIALS) + + _LOGGER.debug( + "Checking %s for explicit credentials as part of auth process...", explicit_file + ) + + if explicit_file is not None and explicit_file == cloud_sdk_adc_path: + # Cloud sdk flow calls gcloud to fetch project id, so if the explicit + # file path is cloud sdk credentials path, then we should fall back + # to cloud sdk flow, otherwise project id cannot be obtained. + _LOGGER.debug( + "Explicit credentials path %s is the same as Cloud SDK credentials path, fall back to Cloud SDK credentials flow...", + explicit_file, + ) + return _get_gcloud_sdk_credentials(quota_project_id=quota_project_id) + + if explicit_file is not None: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + credentials, project_id = load_credentials_from_file( + os.environ[environment_vars.CREDENTIALS], + quota_project_id=quota_project_id, + ) + credentials._cred_file_path = f"{explicit_file} file via the GOOGLE_APPLICATION_CREDENTIALS environment variable" + + return credentials, project_id + + else: + return None, None + + +def _get_gae_credentials(): + """Gets Google App Engine App Identity credentials and project ID.""" + # If not GAE gen1, prefer the metadata service even if the GAE APIs are + # available as per https://google.aip.dev/auth/4115. + if os.environ.get(environment_vars.LEGACY_APPENGINE_RUNTIME) != "python27": + return None, None + + # While this library is normally bundled with app_engine, there are + # some cases where it's not available, so we tolerate ImportError. + try: + _LOGGER.debug("Checking for App Engine runtime as part of auth process...") + import google.auth.app_engine as app_engine + except ImportError: + _LOGGER.warning("Import of App Engine auth library failed.") + return None, None + + try: + credentials = app_engine.Credentials() + project_id = app_engine.get_project_id() + return credentials, project_id + except EnvironmentError: + _LOGGER.debug( + "No App Engine library was found so cannot authentication via App Engine Identity Credentials." + ) + return None, None + + +def _get_gce_credentials(request=None, quota_project_id=None): + """Gets credentials and project ID from the GCE Metadata Service.""" + # Ping requires a transport, but we want application default credentials + # to require no arguments. So, we'll use the _http_client transport which + # uses http.client. This is only acceptable because the metadata server + # doesn't do SSL and never requires proxies. + + # While this library is normally bundled with compute_engine, there are + # some cases where it's not available, so we tolerate ImportError. + try: + from google.auth import compute_engine + from google.auth.compute_engine import _metadata + except ImportError: + _LOGGER.warning("Import of Compute Engine auth library failed.") + return None, None + + if request is None: + request = google.auth.transport._http_client.Request() + + if _metadata.is_on_gce(request=request): + # Get the project ID. + try: + project_id = _metadata.get_project_id(request=request) + except exceptions.TransportError: + project_id = None + + cred = compute_engine.Credentials() + cred = _apply_quota_project_id(cred, quota_project_id) + + return cred, project_id + else: + _LOGGER.warning( + "Authentication failed using Compute Engine authentication due to unavailable metadata server." + ) + return None, None + + +def _get_external_account_credentials( + info, filename, scopes=None, default_scopes=None, request=None +): + """Loads external account Credentials from the parsed external account info. + + The credentials information must correspond to a supported external account + credentials. + + Args: + info (Mapping[str, str]): The external account info in Google format. + filename (str): The full path to the credentials file. + scopes (Optional[Sequence[str]]): The list of scopes for the credentials. If + specified, the credentials will automatically be scoped if + necessary. + default_scopes (Optional[Sequence[str]]): Default scopes passed by a + Google client library. Use 'scopes' for user-defined scopes. + request (Optional[google.auth.transport.Request]): An object used to make + HTTP requests. This is used to determine the associated project ID + for a workload identity pool resource (external account credentials). + If not specified, then it will use a + google.auth.transport.requests.Request client to make requests. + + Returns: + Tuple[google.auth.credentials.Credentials, Optional[str]]: Loaded + credentials and the project ID. External account credentials project + IDs may not always be determined. + + Raises: + google.auth.exceptions.DefaultCredentialsError: if the info dictionary + is in the wrong format or is missing required information. + """ + # There are currently 3 types of external_account credentials. + if info.get("subject_token_type") == _AWS_SUBJECT_TOKEN_TYPE: + # Check if configuration corresponds to an AWS credentials. + from google.auth import aws + + credentials = aws.Credentials.from_info( + info, scopes=scopes, default_scopes=default_scopes + ) + elif ( + info.get("credential_source") is not None + and info.get("credential_source").get("executable") is not None + ): + from google.auth import pluggable + + credentials = pluggable.Credentials.from_info( + info, scopes=scopes, default_scopes=default_scopes + ) + else: + try: + # Check if configuration corresponds to an Identity Pool credentials. + from google.auth import identity_pool + + credentials = identity_pool.Credentials.from_info( + info, scopes=scopes, default_scopes=default_scopes + ) + except ValueError: + # If the configuration is invalid or does not correspond to any + # supported external_account credentials, raise an error. + raise exceptions.DefaultCredentialsError( + "Failed to load external account credentials from {}".format(filename) + ) + if request is None: + import google.auth.transport.requests + + request = google.auth.transport.requests.Request() + + return credentials, credentials.get_project_id(request=request) + + +def _get_external_account_authorized_user_credentials( + filename, info, scopes=None, default_scopes=None, request=None +): + try: + from google.auth import external_account_authorized_user + + credentials = external_account_authorized_user.Credentials.from_info(info) + except ValueError: + raise exceptions.DefaultCredentialsError( + "Failed to load external account authorized user credentials from {}".format( + filename + ) + ) + + return credentials, None + + +def _get_authorized_user_credentials(filename, info, scopes=None): + from google.oauth2 import credentials + + try: + credentials = credentials.Credentials.from_authorized_user_info( + info, scopes=scopes + ) + except ValueError as caught_exc: + msg = "Failed to load authorized user credentials from {}".format(filename) + new_exc = exceptions.DefaultCredentialsError(msg, caught_exc) + raise new_exc from caught_exc + return credentials, None + + +def _get_service_account_credentials(filename, info, scopes=None, default_scopes=None): + from google.oauth2 import service_account + + try: + credentials = service_account.Credentials.from_service_account_info( + info, scopes=scopes, default_scopes=default_scopes + ) + except ValueError as caught_exc: + msg = "Failed to load service account credentials from {}".format(filename) + new_exc = exceptions.DefaultCredentialsError(msg, caught_exc) + raise new_exc from caught_exc + return credentials, info.get("project_id") + + +def _get_impersonated_service_account_credentials(filename, info, scopes): + from google.auth import impersonated_credentials + + try: + credentials = ( + impersonated_credentials.Credentials.from_impersonated_service_account_info( + info, scopes=scopes + ) + ) + except ValueError as caught_exc: + msg = "Failed to load impersonated service account credentials from {}".format( + filename + ) + new_exc = exceptions.DefaultCredentialsError(msg, caught_exc) + raise new_exc from caught_exc + return credentials, None + + +def _get_gdch_service_account_credentials(filename, info): + from google.oauth2 import gdch_credentials + + try: + credentials = ( + gdch_credentials.ServiceAccountCredentials.from_service_account_info(info) + ) + except ValueError as caught_exc: + msg = "Failed to load GDCH service account credentials from {}".format(filename) + new_exc = exceptions.DefaultCredentialsError(msg, caught_exc) + raise new_exc from caught_exc + return credentials, info.get("project") + + +def get_api_key_credentials(key): + """Return credentials with the given API key.""" + from google.auth import api_key + + return api_key.Credentials(key) + + +def _apply_quota_project_id(credentials, quota_project_id): + if quota_project_id: + credentials = credentials.with_quota_project(quota_project_id) + else: + credentials = credentials.with_quota_project_from_environment() + + from google.oauth2 import credentials as authorized_user_credentials + + if isinstance(credentials, authorized_user_credentials.Credentials) and ( + not credentials.quota_project_id + ): + _warn_about_problematic_credentials(credentials) + return credentials + + +def default(scopes=None, request=None, quota_project_id=None, default_scopes=None): + """Gets the default credentials for the current environment. + + `Application Default Credentials`_ provides an easy way to obtain + credentials to call Google APIs for server-to-server or local applications. + This function acquires credentials from the environment in the following + order: + + 1. If the environment variable ``GOOGLE_APPLICATION_CREDENTIALS`` is set + to the path of a valid service account JSON private key file, then it is + loaded and returned. The project ID returned is the project ID defined + in the service account file if available (some older files do not + contain project ID information). + + If the environment variable is set to the path of a valid external + account JSON configuration file (workload identity federation), then the + configuration file is used to determine and retrieve the external + credentials from the current environment (AWS, Azure, etc). + These will then be exchanged for Google access tokens via the Google STS + endpoint. + The project ID returned in this case is the one corresponding to the + underlying workload identity pool resource if determinable. + + If the environment variable is set to the path of a valid GDCH service + account JSON file (`Google Distributed Cloud Hosted`_), then a GDCH + credential will be returned. The project ID returned is the project + specified in the JSON file. + 2. If the `Google Cloud SDK`_ is installed and has application default + credentials set they are loaded and returned. + + To enable application default credentials with the Cloud SDK run:: + + gcloud auth application-default login + + If the Cloud SDK has an active project, the project ID is returned. The + active project can be set using:: + + gcloud config set project + + 3. If the application is running in the `App Engine standard environment`_ + (first generation) then the credentials and project ID from the + `App Identity Service`_ are used. + 4. If the application is running in `Compute Engine`_ or `Cloud Run`_ or + the `App Engine flexible environment`_ or the `App Engine standard + environment`_ (second generation) then the credentials and project ID + are obtained from the `Metadata Service`_. + 5. If no credentials are found, + :class:`~google.auth.exceptions.DefaultCredentialsError` will be raised. + + .. _Application Default Credentials: https://developers.google.com\ + /identity/protocols/application-default-credentials + .. _Google Cloud SDK: https://cloud.google.com/sdk + .. _App Engine standard environment: https://cloud.google.com/appengine + .. _App Identity Service: https://cloud.google.com/appengine/docs/python\ + /appidentity/ + .. _Compute Engine: https://cloud.google.com/compute + .. _App Engine flexible environment: https://cloud.google.com\ + /appengine/flexible + .. _Metadata Service: https://cloud.google.com/compute/docs\ + /storing-retrieving-metadata + .. _Cloud Run: https://cloud.google.com/run + .. _Google Distributed Cloud Hosted: https://cloud.google.com/blog/topics\ + /hybrid-cloud/announcing-google-distributed-cloud-edge-and-hosted + + Example:: + + import google.auth + + credentials, project_id = google.auth.default() + + Args: + scopes (Sequence[str]): The list of scopes for the credentials. If + specified, the credentials will automatically be scoped if + necessary. + request (Optional[google.auth.transport.Request]): An object used to make + HTTP requests. This is used to either detect whether the application + is running on Compute Engine or to determine the associated project + ID for a workload identity pool resource (external account + credentials). If not specified, then it will either use the standard + library http client to make requests for Compute Engine credentials + or a google.auth.transport.requests.Request client for external + account credentials. + quota_project_id (Optional[str]): The project ID used for + quota and billing. + default_scopes (Optional[Sequence[str]]): Default scopes passed by a + Google client library. Use 'scopes' for user-defined scopes. + Returns: + Tuple[~google.auth.credentials.Credentials, Optional[str]]: + the current environment's credentials and project ID. Project ID + may be None, which indicates that the Project ID could not be + ascertained from the environment. + + Raises: + ~google.auth.exceptions.DefaultCredentialsError: + If no credentials were found, or if the credentials found were + invalid. + """ + from google.auth.credentials import with_scopes_if_required + from google.auth.credentials import CredentialsWithQuotaProject + + explicit_project_id = os.environ.get( + environment_vars.PROJECT, os.environ.get(environment_vars.LEGACY_PROJECT) + ) + + checkers = ( + # Avoid passing scopes here to prevent passing scopes to user credentials. + # with_scopes_if_required() below will ensure scopes/default scopes are + # safely set on the returned credentials since requires_scopes will + # guard against setting scopes on user credentials. + lambda: _get_explicit_environ_credentials(quota_project_id=quota_project_id), + lambda: _get_gcloud_sdk_credentials(quota_project_id=quota_project_id), + _get_gae_credentials, + lambda: _get_gce_credentials(request, quota_project_id=quota_project_id), + ) + + for checker in checkers: + credentials, project_id = checker() + if credentials is not None: + credentials = with_scopes_if_required( + credentials, scopes, default_scopes=default_scopes + ) + + effective_project_id = explicit_project_id or project_id + + # For external account credentials, scopes are required to determine + # the project ID. Try to get the project ID again if not yet + # determined. + if not effective_project_id and callable( + getattr(credentials, "get_project_id", None) + ): + if request is None: + import google.auth.transport.requests + + request = google.auth.transport.requests.Request() + effective_project_id = credentials.get_project_id(request=request) + + if quota_project_id and isinstance( + credentials, CredentialsWithQuotaProject + ): + credentials = credentials.with_quota_project(quota_project_id) + + if not effective_project_id: + _LOGGER.warning( + "No project ID could be determined. Consider running " + "`gcloud config set project` or setting the %s " + "environment variable", + environment_vars.PROJECT, + ) + return credentials, effective_project_id + + raise exceptions.DefaultCredentialsError(_CLOUD_SDK_MISSING_CREDENTIALS) diff --git a/packages/google-auth/google/auth/_default_async.py b/packages/google-auth/google/auth/_default_async.py new file mode 100644 index 000000000000..44bc6719f97a --- /dev/null +++ b/packages/google-auth/google/auth/_default_async.py @@ -0,0 +1,288 @@ +# Copyright 2020 Google Inc. +# +# 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. + +"""Application default credentials. + +Implements application default credentials and project ID detection. +""" + +import io +import json +import os +import warnings + +from google.auth import _default +from google.auth import environment_vars +from google.auth import exceptions + + +def load_credentials_from_file(filename, scopes=None, quota_project_id=None): + """Loads Google credentials from a file. + + The credentials file must be a service account key or stored authorized + user credentials. + + Args: + filename (str): The full path to the credentials file. + scopes (Optional[Sequence[str]]): The list of scopes for the credentials. If + specified, the credentials will automatically be scoped if + necessary + quota_project_id (Optional[str]): The project ID used for + quota and billing. + + Returns: + Tuple[google.auth.credentials.Credentials, Optional[str]]: Loaded + credentials and the project ID. Authorized user credentials do not + have the project ID information. + + Raises: + google.auth.exceptions.DefaultCredentialsError: if the file is in the + wrong format or is missing. + """ + if not os.path.exists(filename): + raise exceptions.DefaultCredentialsError( + "File {} was not found.".format(filename) + ) + + with io.open(filename, "r") as file_obj: + try: + info = json.load(file_obj) + except ValueError as caught_exc: + new_exc = exceptions.DefaultCredentialsError( + "File {} is not a valid json file.".format(filename), caught_exc + ) + raise new_exc from caught_exc + + # The type key should indicate that the file is either a service account + # credentials file or an authorized user credentials file. + credential_type = info.get("type") + + if credential_type == _default._AUTHORIZED_USER_TYPE: + from google.oauth2 import _credentials_async as credentials + + try: + credentials = credentials.Credentials.from_authorized_user_info( + info, scopes=scopes + ) + except ValueError as caught_exc: + msg = "Failed to load authorized user credentials from {}".format(filename) + new_exc = exceptions.DefaultCredentialsError(msg, caught_exc) + raise new_exc from caught_exc + if quota_project_id: + credentials = credentials.with_quota_project(quota_project_id) + if not credentials.quota_project_id: + _default._warn_about_problematic_credentials(credentials) + return credentials, None + + elif credential_type == _default._SERVICE_ACCOUNT_TYPE: + from google.oauth2 import _service_account_async as service_account + + try: + credentials = service_account.Credentials.from_service_account_info( + info, scopes=scopes + ).with_quota_project(quota_project_id) + except ValueError as caught_exc: + msg = "Failed to load service account credentials from {}".format(filename) + new_exc = exceptions.DefaultCredentialsError(msg, caught_exc) + raise new_exc from caught_exc + return credentials, info.get("project_id") + + else: + raise exceptions.DefaultCredentialsError( + "The file {file} does not have a valid type. " + "Type is {type}, expected one of {valid_types}.".format( + file=filename, type=credential_type, valid_types=_default._VALID_TYPES + ) + ) + + +def _get_gcloud_sdk_credentials(quota_project_id=None): + """Gets the credentials and project ID from the Cloud SDK.""" + from google.auth import _cloud_sdk + + # Check if application default credentials exist. + credentials_filename = _cloud_sdk.get_application_default_credentials_path() + + if not os.path.isfile(credentials_filename): + return None, None + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + credentials, project_id = load_credentials_from_file( + credentials_filename, quota_project_id=quota_project_id + ) + + if not project_id: + project_id = _cloud_sdk.get_project_id() + + return credentials, project_id + + +def _get_explicit_environ_credentials(quota_project_id=None): + """Gets credentials from the GOOGLE_APPLICATION_CREDENTIALS environment + variable.""" + from google.auth import _cloud_sdk + + cloud_sdk_adc_path = _cloud_sdk.get_application_default_credentials_path() + explicit_file = os.environ.get(environment_vars.CREDENTIALS) + + if explicit_file is not None and explicit_file == cloud_sdk_adc_path: + # Cloud sdk flow calls gcloud to fetch project id, so if the explicit + # file path is cloud sdk credentials path, then we should fall back + # to cloud sdk flow, otherwise project id cannot be obtained. + return _get_gcloud_sdk_credentials(quota_project_id=quota_project_id) + + if explicit_file is not None: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + credentials, project_id = load_credentials_from_file( + os.environ[environment_vars.CREDENTIALS], + quota_project_id=quota_project_id, + ) + + return credentials, project_id + + else: + return None, None + + +def _get_gae_credentials(): + """Gets Google App Engine App Identity credentials and project ID.""" + # While this library is normally bundled with app_engine, there are + # some cases where it's not available, so we tolerate ImportError. + + return _default._get_gae_credentials() + + +def _get_gce_credentials(request=None): + """Gets credentials and project ID from the GCE Metadata Service.""" + # Ping requires a transport, but we want application default credentials + # to require no arguments. So, we'll use the _http_client transport which + # uses http.client. This is only acceptable because the metadata server + # doesn't do SSL and never requires proxies. + + # While this library is normally bundled with compute_engine, there are + # some cases where it's not available, so we tolerate ImportError. + + return _default._get_gce_credentials(request) + + +def default_async(scopes=None, request=None, quota_project_id=None): + """Gets the default credentials for the current environment. + + `Application Default Credentials`_ provides an easy way to obtain + credentials to call Google APIs for server-to-server or local applications. + This function acquires credentials from the environment in the following + order: + + 1. If the environment variable ``GOOGLE_APPLICATION_CREDENTIALS`` is set + to the path of a valid service account JSON private key file, then it is + loaded and returned. The project ID returned is the project ID defined + in the service account file if available (some older files do not + contain project ID information). + 2. If the `Google Cloud SDK`_ is installed and has application default + credentials set they are loaded and returned. + + To enable application default credentials with the Cloud SDK run:: + + gcloud auth application-default login + + If the Cloud SDK has an active project, the project ID is returned. The + active project can be set using:: + + gcloud config set project + + 3. If the application is running in the `App Engine standard environment`_ + (first generation) then the credentials and project ID from the + `App Identity Service`_ are used. + 4. If the application is running in `Compute Engine`_ or `Cloud Run`_ or + the `App Engine flexible environment`_ or the `App Engine standard + environment`_ (second generation) then the credentials and project ID + are obtained from the `Metadata Service`_. + 5. If no credentials are found, + :class:`~google.auth.exceptions.DefaultCredentialsError` will be raised. + + .. _Application Default Credentials: https://developers.google.com\ + /identity/protocols/application-default-credentials + .. _Google Cloud SDK: https://cloud.google.com/sdk + .. _App Engine standard environment: https://cloud.google.com/appengine + .. _App Identity Service: https://cloud.google.com/appengine/docs/python\ + /appidentity/ + .. _Compute Engine: https://cloud.google.com/compute + .. _App Engine flexible environment: https://cloud.google.com\ + /appengine/flexible + .. _Metadata Service: https://cloud.google.com/compute/docs\ + /storing-retrieving-metadata + .. _Cloud Run: https://cloud.google.com/run + + Example:: + + import google.auth + + credentials, project_id = google.auth.default() + + Args: + scopes (Sequence[str]): The list of scopes for the credentials. If + specified, the credentials will automatically be scoped if + necessary. + request (google.auth.transport.Request): An object used to make + HTTP requests. This is used to detect whether the application + is running on Compute Engine. If not specified, then it will + use the standard library http client to make requests. + quota_project_id (Optional[str]): The project ID used for + quota and billing. + Returns: + Tuple[~google.auth.credentials.Credentials, Optional[str]]: + the current environment's credentials and project ID. Project ID + may be None, which indicates that the Project ID could not be + ascertained from the environment. + + Raises: + ~google.auth.exceptions.DefaultCredentialsError: + If no credentials were found, or if the credentials found were + invalid. + """ + from google.auth._credentials_async import with_scopes_if_required + from google.auth.credentials import CredentialsWithQuotaProject + + explicit_project_id = os.environ.get( + environment_vars.PROJECT, os.environ.get(environment_vars.LEGACY_PROJECT) + ) + + checkers = ( + lambda: _get_explicit_environ_credentials(quota_project_id=quota_project_id), + lambda: _get_gcloud_sdk_credentials(quota_project_id=quota_project_id), + _get_gae_credentials, + lambda: _get_gce_credentials(request), + ) + + for checker in checkers: + credentials, project_id = checker() + if credentials is not None: + credentials = with_scopes_if_required(credentials, scopes) + if quota_project_id and isinstance( + credentials, CredentialsWithQuotaProject + ): + credentials = credentials.with_quota_project(quota_project_id) + effective_project_id = explicit_project_id or project_id + if not effective_project_id: + _default._LOGGER.warning( + "No project ID could be determined. Consider running " + "`gcloud config set project` or setting the %s " + "environment variable", + environment_vars.PROJECT, + ) + return credentials, effective_project_id + + raise exceptions.DefaultCredentialsError(_default._CLOUD_SDK_MISSING_CREDENTIALS) diff --git a/packages/google-auth/google/auth/_exponential_backoff.py b/packages/google-auth/google/auth/_exponential_backoff.py new file mode 100644 index 000000000000..89853448f9fc --- /dev/null +++ b/packages/google-auth/google/auth/_exponential_backoff.py @@ -0,0 +1,164 @@ +# 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. + +import asyncio +import random +import time + +from google.auth import exceptions + +# The default amount of retry attempts +_DEFAULT_RETRY_TOTAL_ATTEMPTS = 3 + +# The default initial backoff period (1.0 second). +_DEFAULT_INITIAL_INTERVAL_SECONDS = 1.0 + +# The default randomization factor (0.1 which results in a random period ranging +# between 10% below and 10% above the retry interval). +_DEFAULT_RANDOMIZATION_FACTOR = 0.1 + +# The default multiplier value (2 which is 100% increase per back off). +_DEFAULT_MULTIPLIER = 2.0 + +"""Exponential Backoff Utility + +This is a private module that implements the exponential back off algorithm. +It can be used as a utility for code that needs to retry on failure, for example +an HTTP request. +""" + + +class _BaseExponentialBackoff: + """An exponential backoff iterator base class. + + Args: + total_attempts Optional[int]: + The maximum amount of retries that should happen. + The default value is 3 attempts. + initial_wait_seconds Optional[int]: + The amount of time to sleep in the first backoff. This parameter + should be in seconds. + The default value is 1 second. + randomization_factor Optional[float]: + The amount of jitter that should be in each backoff. For example, + a value of 0.1 will introduce a jitter range of 10% to the + current backoff period. + The default value is 0.1. + multiplier Optional[float]: + The backoff multipler. This adjusts how much each backoff will + increase. For example a value of 2.0 leads to a 200% backoff + on each attempt. If the initial_wait is 1.0 it would look like + this sequence [1.0, 2.0, 4.0, 8.0]. + The default value is 2.0. + """ + + def __init__( + self, + total_attempts=_DEFAULT_RETRY_TOTAL_ATTEMPTS, + initial_wait_seconds=_DEFAULT_INITIAL_INTERVAL_SECONDS, + randomization_factor=_DEFAULT_RANDOMIZATION_FACTOR, + multiplier=_DEFAULT_MULTIPLIER, + ): + if total_attempts < 1: + raise exceptions.InvalidValue( + f"total_attempts must be greater than or equal to 1 but was {total_attempts}" + ) + + self._total_attempts = total_attempts + self._initial_wait_seconds = initial_wait_seconds + + self._current_wait_in_seconds = self._initial_wait_seconds + + self._randomization_factor = randomization_factor + self._multiplier = multiplier + self._backoff_count = 0 + + @property + def total_attempts(self): + """The total amount of backoff attempts that will be made.""" + return self._total_attempts + + @property + def backoff_count(self): + """The current amount of backoff attempts that have been made.""" + return self._backoff_count + + def _reset(self): + self._backoff_count = 0 + self._current_wait_in_seconds = self._initial_wait_seconds + + def _calculate_jitter(self): + jitter_variance = self._current_wait_in_seconds * self._randomization_factor + jitter = random.uniform( + self._current_wait_in_seconds - jitter_variance, + self._current_wait_in_seconds + jitter_variance, + ) + + return jitter + + +class ExponentialBackoff(_BaseExponentialBackoff): + """An exponential backoff iterator. This can be used in a for loop to + perform requests with exponential backoff. + """ + + def __init__(self, *args, **kwargs): + super(ExponentialBackoff, self).__init__(*args, **kwargs) + + def __iter__(self): + self._reset() + return self + + def __next__(self): + if self._backoff_count >= self._total_attempts: + raise StopIteration + self._backoff_count += 1 + + if self._backoff_count <= 1: + return self._backoff_count + + jitter = self._calculate_jitter() + + time.sleep(jitter) + + self._current_wait_in_seconds *= self._multiplier + return self._backoff_count + + +class AsyncExponentialBackoff(_BaseExponentialBackoff): + """An async exponential backoff iterator. This can be used in a for loop to + perform async requests with exponential backoff. + """ + + def __init__(self, *args, **kwargs): + super(AsyncExponentialBackoff, self).__init__(*args, **kwargs) + + def __aiter__(self): + self._reset() + return self + + async def __anext__(self): + if self._backoff_count >= self._total_attempts: + raise StopAsyncIteration + self._backoff_count += 1 + + if self._backoff_count <= 1: + return self._backoff_count + + jitter = self._calculate_jitter() + + await asyncio.sleep(jitter) + + self._current_wait_in_seconds *= self._multiplier + return self._backoff_count diff --git a/packages/google-auth/google/auth/_helpers.py b/packages/google-auth/google/auth/_helpers.py new file mode 100644 index 000000000000..4a69d4c61f9d --- /dev/null +++ b/packages/google-auth/google/auth/_helpers.py @@ -0,0 +1,554 @@ +# Copyright 2015 Google Inc. +# +# 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. + +"""Helper functions for commonly used utilities.""" + +import base64 +import calendar +import datetime +from email.message import Message +import hashlib +import json +import logging +import os +import sys +from typing import Any, Dict, Mapping, Optional, Union +import urllib + +from google.auth import exceptions + + +# _BASE_LOGGER_NAME is the base logger for all google-based loggers. +_BASE_LOGGER_NAME = "google" + +# _LOGGING_INITIALIZED ensures that base logger is only configured once +# (unless already configured by the end-user). +_LOGGING_INITIALIZED = False + + +# The smallest MDS cache used by this library stores tokens until 4 minutes from +# expiry. +REFRESH_THRESHOLD = datetime.timedelta(minutes=3, seconds=45) + +# TODO(https://github.com/googleapis/google-auth-library-python/issues/1684): Audit and update the list below. +_SENSITIVE_FIELDS = { + "accessToken", + "access_token", + "id_token", + "client_id", + "refresh_token", + "client_secret", +} + + +def copy_docstring(source_class): + """Decorator that copies a method's docstring from another class. + + Args: + source_class (type): The class that has the documented method. + + Returns: + Callable: A decorator that will copy the docstring of the same + named method in the source class to the decorated method. + """ + + def decorator(method): + """Decorator implementation. + + Args: + method (Callable): The method to copy the docstring to. + + Returns: + Callable: the same method passed in with an updated docstring. + + Raises: + google.auth.exceptions.InvalidOperation: if the method already has a docstring. + """ + if method.__doc__: + raise exceptions.InvalidOperation("Method already has a docstring.") + + source_method = getattr(source_class, method.__name__) + method.__doc__ = source_method.__doc__ + + return method + + return decorator + + +def parse_content_type(header_value): + """Parse a 'content-type' header value to get just the plain media-type (without parameters). + + This is done using the class Message from email.message as suggested in PEP 594 + (because the cgi is now deprecated and will be removed in python 3.13, + see https://peps.python.org/pep-0594/#cgi). + + Args: + header_value (str): The value of a 'content-type' header as a string. + + Returns: + str: A string with just the lowercase media-type from the parsed 'content-type' header. + If the provided content-type is not parsable, returns 'text/plain', + the default value for textual files. + """ + m = Message() + m["content-type"] = header_value + return ( + m.get_content_type() + ) # Despite the name, actually returns just the media-type + + +def utcnow(): + """Returns the current UTC datetime. + + Returns: + datetime: The current time in UTC. + """ + # We used datetime.utcnow() before, since it's deprecated from python 3.12, + # we are using datetime.now(timezone.utc) now. "utcnow()" is offset-native + # (no timezone info), but "now()" is offset-aware (with timezone info). + # This will cause datetime comparison problem. For backward compatibility, + # we need to remove the timezone info. + now = datetime.datetime.now(datetime.timezone.utc) + now = now.replace(tzinfo=None) + return now + + +def datetime_to_secs(value): + """Convert a datetime object to the number of seconds since the UNIX epoch. + + Args: + value (datetime): The datetime to convert. + + Returns: + int: The number of seconds since the UNIX epoch. + """ + return calendar.timegm(value.utctimetuple()) + + +def to_bytes(value, encoding="utf-8"): + """Converts a string value to bytes, if necessary. + + Args: + value (Union[str, bytes]): The value to be converted. + encoding (str): The encoding to use to convert unicode to bytes. + Defaults to "utf-8". + + Returns: + bytes: The original value converted to bytes (if unicode) or as + passed in if it started out as bytes. + + Raises: + google.auth.exceptions.InvalidValue: If the value could not be converted to bytes. + """ + result = value.encode(encoding) if isinstance(value, str) else value + if isinstance(result, bytes): + return result + else: + raise exceptions.InvalidValue( + "{0!r} could not be converted to bytes".format(value) + ) + + +def from_bytes(value): + """Converts bytes to a string value, if necessary. + + Args: + value (Union[str, bytes]): The value to be converted. + + Returns: + str: The original value converted to unicode (if bytes) or as passed in + if it started out as unicode. + + Raises: + google.auth.exceptions.InvalidValue: If the value could not be converted to unicode. + """ + result = value.decode("utf-8") if isinstance(value, bytes) else value + if isinstance(result, str): + return result + else: + raise exceptions.InvalidValue( + "{0!r} could not be converted to unicode".format(value) + ) + + +def update_query(url, params, remove=None): + """Updates a URL's query parameters. + + Replaces any current values if they are already present in the URL. + + Args: + url (str): The URL to update. + params (Mapping[str, str]): A mapping of query parameter + keys to values. + remove (Sequence[str]): Parameters to remove from the query string. + + Returns: + str: The URL with updated query parameters. + + Examples: + + >>> url = 'http://example.com?a=1' + >>> update_query(url, {'a': '2'}) + http://example.com?a=2 + >>> update_query(url, {'b': '3'}) + http://example.com?a=1&b=3 + >> update_query(url, {'b': '3'}, remove=['a']) + http://example.com?b=3 + + """ + if remove is None: + remove = [] + + # Split the URL into parts. + parts = urllib.parse.urlparse(url) + # Parse the query string. + query_params = urllib.parse.parse_qs(parts.query) + # Update the query parameters with the new parameters. + query_params.update(params) + # Remove any values specified in remove. + query_params = { + key: value for key, value in query_params.items() if key not in remove + } + # Re-encoded the query string. + new_query = urllib.parse.urlencode(query_params, doseq=True) + # Unsplit the url. + new_parts = parts._replace(query=new_query) + return urllib.parse.urlunparse(new_parts) + + +def scopes_to_string(scopes): + """Converts scope value to a string suitable for sending to OAuth 2.0 + authorization servers. + + Args: + scopes (Sequence[str]): The sequence of scopes to convert. + + Returns: + str: The scopes formatted as a single string. + """ + return " ".join(scopes) + + +def string_to_scopes(scopes): + """Converts stringifed scopes value to a list. + + Args: + scopes (Union[Sequence, str]): The string of space-separated scopes + to convert. + Returns: + Sequence(str): The separated scopes. + """ + if not scopes: + return [] + + return scopes.split(" ") + + +def padded_urlsafe_b64decode(value): + """Decodes base64 strings lacking padding characters. + + Google infrastructure tends to omit the base64 padding characters. + + Args: + value (Union[str, bytes]): The encoded value. + + Returns: + bytes: The decoded value + """ + b64string = to_bytes(value) + padded = b64string + b"=" * (-len(b64string) % 4) + return base64.urlsafe_b64decode(padded) + + +def unpadded_urlsafe_b64encode(value): + """Encodes base64 strings removing any padding characters. + + `rfc 7515`_ defines Base64url to NOT include any padding + characters, but the stdlib doesn't do that by default. + + _rfc7515: https://tools.ietf.org/html/rfc7515#page-6 + + Args: + value (Union[str|bytes]): The bytes-like value to encode + + Returns: + Union[str|bytes]: The encoded value + """ + return base64.urlsafe_b64encode(value).rstrip(b"=") + + +def get_bool_from_env(variable_name, default=False): + """Gets a boolean value from an environment variable. + + The environment variable is interpreted as a boolean with the following + (case-insensitive) rules: + - "true", "1" are considered true. + - "false", "0" are considered false. + Any other values will raise an exception. + + Args: + variable_name (str): The name of the environment variable. + default (bool): The default value if the environment variable is not + set. + + Returns: + bool: The boolean value of the environment variable. + + Raises: + google.auth.exceptions.InvalidValue: If the environment variable is + set to a value that can not be interpreted as a boolean. + """ + value = os.environ.get(variable_name) + + if value is None: + return default + + value = value.lower() + + if value in ("true", "1"): + return True + elif value in ("false", "0"): + return False + else: + raise exceptions.InvalidValue( + 'Environment variable "{}" must be one of "true", "false", "1", or "0".'.format( + variable_name + ) + ) + + +def is_python_3(): + """Check if the Python interpreter is Python 2 or 3. + + Returns: + bool: True if the Python interpreter is Python 3 and False otherwise. + """ + return sys.version_info > (3, 0) + + +def _hash_sensitive_info(data: Union[dict, list]) -> Union[dict, list, str]: + """ + Hashes sensitive information within a dictionary. + + Args: + data: The dictionary containing data to be processed. + + Returns: + A new dictionary with sensitive values replaced by their SHA512 hashes. + If the input is a list, returns a list with each element recursively processed. + If the input is neither a dict nor a list, returns the type of the input as a string. + + """ + if isinstance(data, dict): + hashed_data: Dict[Any, Union[Optional[str], dict, list]] = {} + for key, value in data.items(): + if key in _SENSITIVE_FIELDS and not isinstance(value, (dict, list)): + hashed_data[key] = _hash_value(value, key) + elif isinstance(value, (dict, list)): + hashed_data[key] = _hash_sensitive_info(value) + else: + hashed_data[key] = value + return hashed_data + elif isinstance(data, list): + hashed_list = [] + for val in data: + hashed_list.append(_hash_sensitive_info(val)) + return hashed_list + else: + # TODO(https://github.com/googleapis/google-auth-library-python/issues/1701): + # Investigate and hash sensitive info before logging when the data type is + # not a dict or a list. + return str(type(data)) + + +def _hash_value(value, field_name: str) -> Optional[str]: + """Hashes a value and returns a formatted hash string.""" + if value is None: + return None + encoded_value = str(value).encode("utf-8") + hash_object = hashlib.sha512() + hash_object.update(encoded_value) + hex_digest = hash_object.hexdigest() + return f"hashed_{field_name}-{hex_digest}" + + +def _logger_configured(logger: logging.Logger) -> bool: + """Determines whether `logger` has non-default configuration + + Args: + logger: The logger to check. + + Returns: + bool: Whether the logger has any non-default configuration. + """ + return ( + logger.handlers != [] or logger.level != logging.NOTSET or not logger.propagate + ) + + +def is_logging_enabled(logger: logging.Logger) -> bool: + """ + Checks if debug logging is enabled for the given logger. + + Args: + logger: The logging.Logger instance to check. + + Returns: + True if debug logging is enabled, False otherwise. + """ + # NOTE: Log propagation to the root logger is disabled unless + # the base logger i.e. logging.getLogger("google") is + # explicitly configured by the end user. Ideally this + # needs to happen in the client layer (already does for GAPICs). + # However, this is implemented here to avoid logging + # (if a root logger is configured) when a version of google-auth + # which supports logging is used with: + # - an older version of a GAPIC which does not support logging. + # - Apiary client which does not support logging. + global _LOGGING_INITIALIZED + if not _LOGGING_INITIALIZED: + base_logger = logging.getLogger(_BASE_LOGGER_NAME) + if not _logger_configured(base_logger): + base_logger.propagate = False + _LOGGING_INITIALIZED = True + + return logger.isEnabledFor(logging.DEBUG) + + +def request_log( + logger: logging.Logger, + method: str, + url: str, + body: Optional[bytes], + headers: Optional[Mapping[str, str]], +) -> None: + """ + Logs an HTTP request at the DEBUG level if logging is enabled. + + Args: + logger: The logging.Logger instance to use. + method: The HTTP method (e.g., "GET", "POST"). + url: The URL of the request. + body: The request body (can be None). + headers: The request headers (can be None). + """ + if is_logging_enabled(logger): + content_type = ( + headers["Content-Type"] if headers and "Content-Type" in headers else "" + ) + json_body = _parse_request_body(body, content_type=content_type) + logged_body = _hash_sensitive_info(json_body) + logger.debug( + "Making request...", + extra={ + "httpRequest": { + "method": method, + "url": url, + "body": logged_body, + "headers": headers, + } + }, + ) + + +def _parse_request_body(body: Optional[bytes], content_type: str = "") -> Any: + """ + Parses a request body, handling bytes and string types, and different content types. + + Args: + body (Optional[bytes]): The request body. + content_type (str): The content type of the request body, e.g., "application/json", + "application/x-www-form-urlencoded", or "text/plain". If empty, attempts + to parse as JSON. + + Returns: + Parsed body (dict, str, or None). + - JSON: Decodes if content_type is "application/json" or None (fallback). + - URL-encoded: Parses if content_type is "application/x-www-form-urlencoded". + - Plain text: Returns string if content_type is "text/plain". + - None: Returns if body is None, UTF-8 decode fails, or content_type is unknown. + """ + if body is None: + return None + try: + body_str = body.decode("utf-8") + except (UnicodeDecodeError, AttributeError): + return None + content_type = content_type.lower() + if not content_type or "application/json" in content_type: + try: + return json.loads(body_str) + except (TypeError, ValueError): + return body_str + if "application/x-www-form-urlencoded" in content_type: + parsed_query = urllib.parse.parse_qs(body_str) + result = {k: v[0] for k, v in parsed_query.items()} + return result + if "text/plain" in content_type: + return body_str + return None + + +def _parse_response(response: Any) -> Any: + """ + Parses a response, attempting to decode JSON. + + Args: + response: The response object to parse. This can be any type, but + it is expected to have a `json()` method if it contains JSON. + + Returns: + The parsed response. If the response contains valid JSON, the + decoded JSON object (e.g., a dictionary or list) is returned. + If the response does not have a `json()` method or if the JSON + decoding fails, None is returned. + """ + try: + json_response = response.json() + return json_response + except Exception: + # TODO(https://github.com/googleapis/google-auth-library-python/issues/1744): + # Parse and return response payload as json based on different content types. + return None + + +def _response_log_base(logger: logging.Logger, parsed_response: Any) -> None: + """ + Logs a parsed HTTP response at the DEBUG level. + + This internal helper function takes a parsed response and logs it + using the provided logger. It also applies a hashing function to + potentially sensitive information before logging. + + Args: + logger: The logging.Logger instance to use for logging. + parsed_response: The parsed HTTP response object (e.g., a dictionary, + list, or the original response if parsing failed). + """ + + logged_response = _hash_sensitive_info(parsed_response) + logger.debug("Response received...", extra={"httpResponse": logged_response}) + + +def response_log(logger: logging.Logger, response: Any) -> None: + """ + Logs an HTTP response at the DEBUG level if logging is enabled. + + Args: + logger: The logging.Logger instance to use. + response: The HTTP response object to log. + """ + if is_logging_enabled(logger): + json_response = _parse_response(response) + _response_log_base(logger, json_response) diff --git a/packages/google-auth/google/auth/_jwt_async.py b/packages/google-auth/google/auth/_jwt_async.py new file mode 100644 index 000000000000..3a1abc5b85c9 --- /dev/null +++ b/packages/google-auth/google/auth/_jwt_async.py @@ -0,0 +1,164 @@ +# 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. + +"""JSON Web Tokens + +Provides support for creating (encoding) and verifying (decoding) JWTs, +especially JWTs generated and consumed by Google infrastructure. + +See `rfc7519`_ for more details on JWTs. + +To encode a JWT use :func:`encode`:: + + from google.auth import crypt + from google.auth import jwt_async + + signer = crypt.Signer(private_key) + payload = {'some': 'payload'} + encoded = jwt_async.encode(signer, payload) + +To decode a JWT and verify claims use :func:`decode`:: + + claims = jwt_async.decode(encoded, certs=public_certs) + +You can also skip verification:: + + claims = jwt_async.decode(encoded, verify=False) + +.. _rfc7519: https://tools.ietf.org/html/rfc7519 + + +NOTE: This async support is experimental and marked internal. This surface may +change in minor releases. +""" + +from google.auth import _credentials_async +from google.auth import jwt + + +def encode(signer, payload, header=None, key_id=None): + """Make a signed JWT. + + Args: + signer (google.auth.crypt.Signer): The signer used to sign the JWT. + payload (Mapping[str, str]): The JWT payload. + header (Mapping[str, str]): Additional JWT header payload. + key_id (str): The key id to add to the JWT header. If the + signer has a key id it will be used as the default. If this is + specified it will override the signer's key id. + + Returns: + bytes: The encoded JWT. + """ + return jwt.encode(signer, payload, header, key_id) + + +def decode(token, certs=None, verify=True, audience=None): + """Decode and verify a JWT. + + Args: + token (str): The encoded JWT. + certs (Union[str, bytes, Mapping[str, Union[str, bytes]]]): The + certificate used to validate the JWT signature. If bytes or string, + it must the the public key certificate in PEM format. If a mapping, + it must be a mapping of key IDs to public key certificates in PEM + format. The mapping must contain the same key ID that's specified + in the token's header. + verify (bool): Whether to perform signature and claim validation. + Verification is done by default. + audience (str): The audience claim, 'aud', that this JWT should + contain. If None then the JWT's 'aud' parameter is not verified. + + Returns: + Mapping[str, str]: The deserialized JSON payload in the JWT. + + Raises: + ValueError: if any verification checks failed. + """ + + return jwt.decode(token, certs, verify, audience) + + +class Credentials( + jwt.Credentials, _credentials_async.Signing, _credentials_async.Credentials +): + """Credentials that use a JWT as the bearer token. + + These credentials require an "audience" claim. This claim identifies the + intended recipient of the bearer token. + + The constructor arguments determine the claims for the JWT that is + sent with requests. Usually, you'll construct these credentials with + one of the helper constructors as shown in the next section. + + To create JWT credentials using a Google service account private key + JSON file:: + + audience = 'https://pubsub.googleapis.com/google.pubsub.v1.Publisher' + credentials = jwt_async.Credentials.from_service_account_file( + 'service-account.json', + audience=audience) + + If you already have the service account file loaded and parsed:: + + service_account_info = json.load(open('service_account.json')) + credentials = jwt_async.Credentials.from_service_account_info( + service_account_info, + audience=audience) + + Both helper methods pass on arguments to the constructor, so you can + specify the JWT claims:: + + credentials = jwt_async.Credentials.from_service_account_file( + 'service-account.json', + audience=audience, + additional_claims={'meta': 'data'}) + + You can also construct the credentials directly if you have a + :class:`~google.auth.crypt.Signer` instance:: + + credentials = jwt_async.Credentials( + signer, + issuer='your-issuer', + subject='your-subject', + audience=audience) + + The claims are considered immutable. If you want to modify the claims, + you can easily create another instance using :meth:`with_claims`:: + + new_audience = ( + 'https://pubsub.googleapis.com/google.pubsub.v1.Subscriber') + new_credentials = credentials.with_claims(audience=new_audience) + """ + + +class OnDemandCredentials( + jwt.OnDemandCredentials, _credentials_async.Signing, _credentials_async.Credentials +): + """On-demand JWT credentials. + + Like :class:`Credentials`, this class uses a JWT as the bearer token for + authentication. However, this class does not require the audience at + construction time. Instead, it will generate a new token on-demand for + each request using the request URI as the audience. It caches tokens + so that multiple requests to the same URI do not incur the overhead + of generating a new token every time. + + This behavior is especially useful for `gRPC`_ clients. A gRPC service may + have multiple audience and gRPC clients may not know all of the audiences + required for accessing a particular service. With these credentials, + no knowledge of the audiences is required ahead of time. + + .. _grpc: http://www.grpc.io/ + """ diff --git a/packages/google-auth/google/auth/_oauth2client.py b/packages/google-auth/google/auth/_oauth2client.py new file mode 100644 index 000000000000..8b83ff23c1ae --- /dev/null +++ b/packages/google-auth/google/auth/_oauth2client.py @@ -0,0 +1,167 @@ +# Copyright 2016 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. + +"""Helpers for transitioning from oauth2client to google-auth. + +.. warning:: + This module is private as it is intended to assist first-party downstream + clients with the transition from oauth2client to google-auth. +""" + +from __future__ import absolute_import + +from google.auth import _helpers +import google.auth.app_engine +import google.auth.compute_engine +import google.oauth2.credentials +import google.oauth2.service_account + +try: + import oauth2client.client # type: ignore + import oauth2client.contrib.gce # type: ignore + import oauth2client.service_account # type: ignore +except ImportError as caught_exc: + raise ImportError("oauth2client is not installed.") from caught_exc + +try: + import oauth2client.contrib.appengine # type: ignore + + _HAS_APPENGINE = True +except ImportError: + _HAS_APPENGINE = False + + +_CONVERT_ERROR_TMPL = "Unable to convert {} to a google-auth credentials class." + + +def _convert_oauth2_credentials(credentials): + """Converts to :class:`google.oauth2.credentials.Credentials`. + + Args: + credentials (Union[oauth2client.client.OAuth2Credentials, + oauth2client.client.GoogleCredentials]): The credentials to + convert. + + Returns: + google.oauth2.credentials.Credentials: The converted credentials. + """ + new_credentials = google.oauth2.credentials.Credentials( + token=credentials.access_token, + refresh_token=credentials.refresh_token, + token_uri=credentials.token_uri, + client_id=credentials.client_id, + client_secret=credentials.client_secret, + scopes=credentials.scopes, + ) + + new_credentials._expires = credentials.token_expiry + + return new_credentials + + +def _convert_service_account_credentials(credentials): + """Converts to :class:`google.oauth2.service_account.Credentials`. + + Args: + credentials (Union[ + oauth2client.service_account.ServiceAccountCredentials, + oauth2client.service_account._JWTAccessCredentials]): The + credentials to convert. + + Returns: + google.oauth2.service_account.Credentials: The converted credentials. + """ + info = credentials.serialization_data.copy() + info["token_uri"] = credentials.token_uri + return google.oauth2.service_account.Credentials.from_service_account_info(info) + + +def _convert_gce_app_assertion_credentials(credentials): + """Converts to :class:`google.auth.compute_engine.Credentials`. + + Args: + credentials (oauth2client.contrib.gce.AppAssertionCredentials): The + credentials to convert. + + Returns: + google.oauth2.service_account.Credentials: The converted credentials. + """ + return google.auth.compute_engine.Credentials( + service_account_email=credentials.service_account_email + ) + + +def _convert_appengine_app_assertion_credentials(credentials): + """Converts to :class:`google.auth.app_engine.Credentials`. + + Args: + credentials (oauth2client.contrib.app_engine.AppAssertionCredentials): + The credentials to convert. + + Returns: + google.oauth2.service_account.Credentials: The converted credentials. + """ + # pylint: disable=invalid-name + return google.auth.app_engine.Credentials( + scopes=_helpers.string_to_scopes(credentials.scope), + service_account_id=credentials.service_account_id, + ) + + +_CLASS_CONVERSION_MAP = { + oauth2client.client.OAuth2Credentials: _convert_oauth2_credentials, + oauth2client.client.GoogleCredentials: _convert_oauth2_credentials, + oauth2client.service_account.ServiceAccountCredentials: _convert_service_account_credentials, + oauth2client.service_account._JWTAccessCredentials: _convert_service_account_credentials, + oauth2client.contrib.gce.AppAssertionCredentials: _convert_gce_app_assertion_credentials, +} + +if _HAS_APPENGINE: + _CLASS_CONVERSION_MAP[ + oauth2client.contrib.appengine.AppAssertionCredentials + ] = _convert_appengine_app_assertion_credentials + + +def convert(credentials): + """Convert oauth2client credentials to google-auth credentials. + + This class converts: + + - :class:`oauth2client.client.OAuth2Credentials` to + :class:`google.oauth2.credentials.Credentials`. + - :class:`oauth2client.client.GoogleCredentials` to + :class:`google.oauth2.credentials.Credentials`. + - :class:`oauth2client.service_account.ServiceAccountCredentials` to + :class:`google.oauth2.service_account.Credentials`. + - :class:`oauth2client.service_account._JWTAccessCredentials` to + :class:`google.oauth2.service_account.Credentials`. + - :class:`oauth2client.contrib.gce.AppAssertionCredentials` to + :class:`google.auth.compute_engine.Credentials`. + - :class:`oauth2client.contrib.appengine.AppAssertionCredentials` to + :class:`google.auth.app_engine.Credentials`. + + Returns: + google.auth.credentials.Credentials: The converted credentials. + + Raises: + ValueError: If the credentials could not be converted. + """ + + credentials_class = type(credentials) + + try: + return _CLASS_CONVERSION_MAP[credentials_class](credentials) + except KeyError as caught_exc: + new_exc = ValueError(_CONVERT_ERROR_TMPL.format(credentials_class)) + raise new_exc from caught_exc diff --git a/packages/google-auth/google/auth/_refresh_worker.py b/packages/google-auth/google/auth/_refresh_worker.py new file mode 100644 index 000000000000..1bab21a69e40 --- /dev/null +++ b/packages/google-auth/google/auth/_refresh_worker.py @@ -0,0 +1,109 @@ +# Copyright 2023 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 copy +import logging +import threading + +import google.auth.exceptions as e + +_LOGGER = logging.getLogger(__name__) + + +class RefreshThreadManager: + """ + Organizes exactly one background job that refresh a token. + """ + + def __init__(self): + """Initializes the manager.""" + + self._worker = None + self._lock = threading.Lock() # protects access to worker threads. + + def start_refresh(self, cred, request): + """Starts a refresh thread for the given credentials. + The credentials are refreshed using the request parameter. + request and cred MUST not be None + + Returns True if a background refresh was kicked off. False otherwise. + + Args: + cred: A credentials object. + request: A request object. + Returns: + bool + """ + if cred is None or request is None: + raise e.InvalidValue( + "Unable to start refresh. cred and request must be valid and instantiated objects." + ) + + with self._lock: + if self._worker is not None and self._worker._error_info is not None: + return False + + if self._worker is None or not self._worker.is_alive(): # pragma: NO COVER + self._worker = RefreshThread(cred=cred, request=copy.deepcopy(request)) + self._worker.start() + return True + + def clear_error(self): + """ + Removes any errors that were stored from previous background refreshes. + """ + with self._lock: + if self._worker: + self._worker._error_info = None + + def __getstate__(self): + """Pickle helper that serializes the _lock attribute.""" + state = self.__dict__.copy() + state["_lock"] = None + return state + + def __setstate__(self, state): + """Pickle helper that deserializes the _lock attribute.""" + state["_lock"] = threading.Lock() + self.__dict__.update(state) + + +class RefreshThread(threading.Thread): + """ + Thread that refreshes credentials. + """ + + def __init__(self, cred, request, **kwargs): + """Initializes the thread. + + Args: + cred: A Credential object to refresh. + request: A Request object used to perform a credential refresh. + **kwargs: Additional keyword arguments. + """ + + super().__init__(**kwargs) + self._cred = cred + self._request = request + self._error_info = None + + def run(self): + """ + Perform the credential refresh. + """ + try: + self._cred.refresh(self._request) + except Exception as err: # pragma: NO COVER + _LOGGER.error(f"Background refresh failed due to: {err}") + self._error_info = err diff --git a/packages/google-auth/google/auth/_service_account_info.py b/packages/google-auth/google/auth/_service_account_info.py new file mode 100644 index 000000000000..c432080a907d --- /dev/null +++ b/packages/google-auth/google/auth/_service_account_info.py @@ -0,0 +1,80 @@ +# Copyright 2016 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. + +"""Helper functions for loading data from a Google service account file.""" + +import io +import json + +from google.auth import crypt +from google.auth import exceptions + + +def from_dict(data, require=None, use_rsa_signer=True): + """Validates a dictionary containing Google service account data. + + Creates and returns a :class:`google.auth.crypt.Signer` instance from the + private key specified in the data. + + Args: + data (Mapping[str, str]): The service account data + require (Sequence[str]): List of keys required to be present in the + info. + use_rsa_signer (Optional[bool]): Whether to use RSA signer or EC signer. + We use RSA signer by default. + + Returns: + google.auth.crypt.Signer: A signer created from the private key in the + service account file. + + Raises: + MalformedError: if the data was in the wrong format, or if one of the + required keys is missing. + """ + keys_needed = set(require if require is not None else []) + + missing = keys_needed.difference(data.keys()) + + if missing: + raise exceptions.MalformedError( + "Service account info was not in the expected format, missing " + "fields {}.".format(", ".join(missing)) + ) + + # Create a signer. + if use_rsa_signer: + signer = crypt.RSASigner.from_service_account_info(data) + else: + signer = crypt.EsSigner.from_service_account_info(data) + + return signer + + +def from_filename(filename, require=None, use_rsa_signer=True): + """Reads a Google service account JSON file and returns its parsed info. + + Args: + filename (str): The path to the service account .json file. + require (Sequence[str]): List of keys required to be present in the + info. + use_rsa_signer (Optional[bool]): Whether to use RSA signer or EC signer. + We use RSA signer by default. + + Returns: + Tuple[ Mapping[str, str], google.auth.crypt.Signer ]: The verified + info and a signer instance. + """ + with io.open(filename, "r", encoding="utf-8") as json_file: + data = json.load(json_file) + return data, from_dict(data, require=require, use_rsa_signer=use_rsa_signer) diff --git a/packages/google-auth/google/auth/aio/__init__.py b/packages/google-auth/google/auth/aio/__init__.py new file mode 100644 index 000000000000..331708cba62c --- /dev/null +++ b/packages/google-auth/google/auth/aio/__init__.py @@ -0,0 +1,25 @@ +# Copyright 2024 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. + +"""Google Auth AIO Library for Python.""" + +import logging + +from google.auth import version as google_auth_version + + +__version__ = google_auth_version.__version__ + +# Set default logging handler to avoid "No handler found" warnings. +logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/packages/google-auth/google/auth/aio/_helpers.py b/packages/google-auth/google/auth/aio/_helpers.py new file mode 100644 index 000000000000..1d5a4618cb61 --- /dev/null +++ b/packages/google-auth/google/auth/aio/_helpers.py @@ -0,0 +1,62 @@ +# Copyright 2025 Google Inc. +# +# 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. + +"""Helper functions for commonly used utilities.""" + + +import logging +from typing import Any + +from google.auth import _helpers + + +async def _parse_response_async(response: Any) -> Any: + """ + Parses an async response, attempting to decode JSON. + + Args: + response: The response object to parse. This can be any type, but + it is expected to have a `json()` method if it contains JSON. + + Returns: + The parsed response. If the response contains valid JSON, the + decoded JSON object (e.g., a dictionary) is returned. + If the response does not have a `json()` method or if the JSON + decoding fails, None is returned. + """ + try: + json_response = await response.json() + return json_response + except Exception: + # TODO(https://github.com/googleapis/google-auth-library-python/issues/1745): + # Parse and return response payload as json based on different content types. + return None + + +async def response_log_async(logger: logging.Logger, response: Any) -> None: + """ + Logs an Async HTTP response at the DEBUG level if logging is enabled. + + Args: + logger: The logging.Logger instance to use. + response: The HTTP response object to log. + """ + if _helpers.is_logging_enabled(logger): + # TODO(https://github.com/googleapis/google-auth-library-python/issues/1755): + # Parsing the response for async streaming logging results in + # the stream to be empty downstream. For now, we will not be logging + # the response for async responses until we investigate further. + # json_response = await _parse_response_async(response) + json_response = None + _helpers._response_log_base(logger, json_response) diff --git a/packages/google-auth/google/auth/aio/credentials.py b/packages/google-auth/google/auth/aio/credentials.py new file mode 100644 index 000000000000..3bc6a5a6762a --- /dev/null +++ b/packages/google-auth/google/auth/aio/credentials.py @@ -0,0 +1,143 @@ +# Copyright 2024 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. + + +"""Interfaces for asynchronous credentials.""" + + +from google.auth import _helpers +from google.auth import exceptions +from google.auth._credentials_base import _BaseCredentials + + +class Credentials(_BaseCredentials): + """Base class for all asynchronous credentials. + + All credentials have a :attr:`token` that is used for authentication and + may also optionally set an :attr:`expiry` to indicate when the token will + no longer be valid. + + Most credentials will be :attr:`invalid` until :meth:`refresh` is called. + Credentials can do this automatically before the first HTTP request in + :meth:`before_request`. + + Although the token and expiration will change as the credentials are + :meth:`refreshed ` and used, credentials should be considered + immutable. Various credentials will accept configuration such as private + keys, scopes, and other options. These options are not changeable after + construction. Some classes will provide mechanisms to copy the credentials + with modifications such as :meth:`ScopedCredentials.with_scopes`. + """ + + def __init__(self): + super(Credentials, self).__init__() + + async def apply(self, headers, token=None): + """Apply the token to the authentication header. + + Args: + headers (Mapping): The HTTP request headers. + token (Optional[str]): If specified, overrides the current access + token. + """ + self._apply(headers, token=token) + + async def refresh(self, request): + """Refreshes the access token. + + Args: + request (google.auth.aio.transport.Request): The object used to make + HTTP requests. + + Raises: + google.auth.exceptions.RefreshError: If the credentials could + not be refreshed. + """ + raise NotImplementedError("Refresh must be implemented") + + async def before_request(self, request, method, url, headers): + """Performs credential-specific before request logic. + + Refreshes the credentials if necessary, then calls :meth:`apply` to + apply the token to the authentication header. + + Args: + request (google.auth.aio.transport.Request): The object used to make + HTTP requests. + method (str): The request's HTTP method or the RPC method being + invoked. + url (str): The request's URI or the RPC service's URI. + headers (Mapping): The request's headers. + """ + await self.apply(headers) + + +class StaticCredentials(Credentials): + """Asynchronous Credentials representing an immutable access token. + + The credentials are considered immutable except the tokens which can be + configured in the constructor :: + + credentials = StaticCredentials(token="token123") + + StaticCredentials does not support :meth `refresh` and assumes that the configured + token is valid and not expired. StaticCredentials will never attempt to + refresh the token. + """ + + def __init__(self, token): + """ + Args: + token (str): The access token. + """ + super(StaticCredentials, self).__init__() + self.token = token + + @_helpers.copy_docstring(Credentials) + async def refresh(self, request): + raise exceptions.InvalidOperation("Static credentials cannot be refreshed.") + + # Note: before_request should never try to refresh access tokens. + # StaticCredentials intentionally does not support it. + @_helpers.copy_docstring(Credentials) + async def before_request(self, request, method, url, headers): + await self.apply(headers) + + +class AnonymousCredentials(Credentials): + """Asynchronous Credentials that do not provide any authentication information. + + These are useful in the case of services that support anonymous access or + local service emulators that do not use credentials. + """ + + async def refresh(self, request): + """Raises :class:``InvalidOperation``, anonymous credentials cannot be + refreshed.""" + raise exceptions.InvalidOperation("Anonymous credentials cannot be refreshed.") + + async def apply(self, headers, token=None): + """Anonymous credentials do nothing to the request. + + The optional ``token`` argument is not supported. + + Raises: + google.auth.exceptions.InvalidValue: If a token was specified. + """ + if token is not None: + raise exceptions.InvalidValue("Anonymous credentials don't support tokens.") + + async def before_request(self, request, method, url, headers): + """Anonymous credentials do nothing to the request.""" + pass diff --git a/packages/google-auth/google/auth/aio/transport/__init__.py b/packages/google-auth/google/auth/aio/transport/__init__.py new file mode 100644 index 000000000000..166a3be50914 --- /dev/null +++ b/packages/google-auth/google/auth/aio/transport/__init__.py @@ -0,0 +1,144 @@ +# Copyright 2024 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. + +"""Transport - Asynchronous HTTP client library support. + +:mod:`google.auth.aio` is designed to work with various asynchronous client libraries such +as aiohttp. In order to work across these libraries with different +interfaces some abstraction is needed. + +This module provides two interfaces that are implemented by transport adapters +to support HTTP libraries. :class:`Request` defines the interface expected by +:mod:`google.auth` to make asynchronous requests. :class:`Response` defines the interface +for the return value of :class:`Request`. +""" + +import abc +from typing import AsyncGenerator, Mapping, Optional + +import google.auth.transport + + +_DEFAULT_TIMEOUT_SECONDS = 180 + +DEFAULT_RETRYABLE_STATUS_CODES = google.auth.transport.DEFAULT_RETRYABLE_STATUS_CODES +"""Sequence[int]: HTTP status codes indicating a request can be retried. +""" + + +DEFAULT_MAX_RETRY_ATTEMPTS = 3 +"""int: How many times to retry a request.""" + + +class Response(metaclass=abc.ABCMeta): + """Asynchronous HTTP Response Interface.""" + + @property + @abc.abstractmethod + def status_code(self) -> int: + """ + The HTTP response status code. + + Returns: + int: The HTTP response status code. + + """ + raise NotImplementedError("status_code must be implemented.") + + @property + @abc.abstractmethod + def headers(self) -> Mapping[str, str]: + """The HTTP response headers. + + Returns: + Mapping[str, str]: The HTTP response headers. + """ + raise NotImplementedError("headers must be implemented.") + + @abc.abstractmethod + async def content(self, chunk_size: int) -> AsyncGenerator[bytes, None]: + """The raw response content. + + Args: + chunk_size (int): The size of each chunk. + + Yields: + AsyncGenerator[bytes, None]: An asynchronous generator yielding + response chunks as bytes. + """ + raise NotImplementedError("content must be implemented.") + + @abc.abstractmethod + async def read(self) -> bytes: + """Read the entire response content as bytes. + + Returns: + bytes: The entire response content. + """ + raise NotImplementedError("read must be implemented.") + + @abc.abstractmethod + async def close(self): + """Close the response after it is fully consumed to resource.""" + raise NotImplementedError("close must be implemented.") + + +class Request(metaclass=abc.ABCMeta): + """Interface for a callable that makes HTTP requests. + + Specific transport implementations should provide an implementation of + this that adapts their specific request / response API. + + .. automethod:: __call__ + """ + + @abc.abstractmethod + async def __call__( + self, + url: str, + method: str, + body: Optional[bytes], + headers: Optional[Mapping[str, str]], + timeout: float, + **kwargs + ) -> Response: + """Make an HTTP request. + + Args: + url (str): The URI to be requested. + method (str): The HTTP method to use for the request. Defaults + to 'GET'. + body (Optional[bytes]): The payload / body in HTTP request. + headers (Mapping[str, str]): Request headers. + timeout (float): The number of seconds to wait for a + response from the server. If not specified or if None, the + transport-specific default timeout will be used. + kwargs: Additional arguments passed on to the transport's + request method. + + Returns: + google.auth.aio.transport.Response: The HTTP response. + + Raises: + google.auth.exceptions.TransportError: If any exception occurred. + """ + # pylint: disable=redundant-returns-doc, missing-raises-doc + # (pylint doesn't play well with abstract docstrings.) + raise NotImplementedError("__call__ must be implemented.") + + async def close(self) -> None: + """ + Close the underlying session. + """ + raise NotImplementedError("close must be implemented.") diff --git a/packages/google-auth/google/auth/aio/transport/aiohttp.py b/packages/google-auth/google/auth/aio/transport/aiohttp.py new file mode 100644 index 000000000000..67a19f952d24 --- /dev/null +++ b/packages/google-auth/google/auth/aio/transport/aiohttp.py @@ -0,0 +1,190 @@ +# Copyright 2024 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. + +"""Transport adapter for Asynchronous HTTP Requests based on aiohttp. +""" + +import asyncio +import logging +from typing import AsyncGenerator, Mapping, Optional + +try: + import aiohttp # type: ignore +except ImportError as caught_exc: # pragma: NO COVER + raise ImportError( + "The aiohttp library is not installed from please install the aiohttp package to use the aiohttp transport." + ) from caught_exc + +from google.auth import _helpers +from google.auth import exceptions +from google.auth.aio import _helpers as _helpers_async +from google.auth.aio import transport + +_LOGGER = logging.getLogger(__name__) + + +class Response(transport.Response): + """ + Represents an HTTP response and its data. It is returned by ``google.auth.aio.transport.sessions.AsyncAuthorizedSession``. + + Args: + response (aiohttp.ClientResponse): An instance of aiohttp.ClientResponse. + + Attributes: + status_code (int): The HTTP status code of the response. + headers (Mapping[str, str]): The HTTP headers of the response. + """ + + def __init__(self, response: aiohttp.ClientResponse): + self._response = response + + @property + @_helpers.copy_docstring(transport.Response) + def status_code(self) -> int: + return self._response.status + + @property + @_helpers.copy_docstring(transport.Response) + def headers(self) -> Mapping[str, str]: + return {key: value for key, value in self._response.headers.items()} + + @_helpers.copy_docstring(transport.Response) + async def content(self, chunk_size: int = 1024) -> AsyncGenerator[bytes, None]: + try: + async for chunk in self._response.content.iter_chunked( + chunk_size + ): # pragma: no branch + yield chunk + except aiohttp.ClientPayloadError as exc: + raise exceptions.ResponseError( + "Failed to read from the payload stream." + ) from exc + + @_helpers.copy_docstring(transport.Response) + async def read(self) -> bytes: + try: + return await self._response.read() + except aiohttp.ClientResponseError as exc: + raise exceptions.ResponseError("Failed to read the response body.") from exc + + @_helpers.copy_docstring(transport.Response) + async def close(self): + self._response.close() + + +class Request(transport.Request): + """Asynchronous Requests request adapter. + + This class is used internally for making requests using aiohttp + in a consistent way. If you use :class:`google.auth.aio.transport.sessions.AsyncAuthorizedSession` + you do not need to construct or use this class directly. + + This class can be useful if you want to configure a Request callable + with a custom ``aiohttp.ClientSession`` in :class:`AuthorizedSession` or if + you want to manually refresh a :class:`~google.auth.aio.credentials.Credentials` instance:: + + import aiohttp + import google.auth.aio.transport.aiohttp + + # Default example: + request = google.auth.aio.transport.aiohttp.Request() + await credentials.refresh(request) + + # Custom aiohttp Session Example: + session = session=aiohttp.ClientSession(auto_decompress=False) + request = google.auth.aio.transport.aiohttp.Request(session=session) + auth_sesion = google.auth.aio.transport.sessions.AsyncAuthorizedSession(auth_request=request) + + Args: + session (aiohttp.ClientSession): An instance :class:`aiohttp.ClientSession` used + to make HTTP requests. If not specified, a session will be created. + + .. automethod:: __call__ + """ + + def __init__(self, session: aiohttp.ClientSession = None): + self._session = session + self._closed = False + + async def __call__( + self, + url: str, + method: str = "GET", + body: Optional[bytes] = None, + headers: Optional[Mapping[str, str]] = None, + timeout: float = transport._DEFAULT_TIMEOUT_SECONDS, + **kwargs, + ) -> transport.Response: + """ + Make an HTTP request using aiohttp. + + Args: + url (str): The URL to be requested. + method (Optional[str]): + The HTTP method to use for the request. Defaults to 'GET'. + body (Optional[bytes]): + The payload or body in HTTP request. + headers (Optional[Mapping[str, str]]): + Request headers. + timeout (float): The number of seconds to wait for a + response from the server. If not specified or if None, the + requests default timeout will be used. + kwargs: Additional arguments passed through to the underlying + aiohttp :meth:`aiohttp.Session.request` method. + + Returns: + google.auth.aio.transport.Response: The HTTP response. + + Raises: + - google.auth.exceptions.TransportError: If the request fails or if the session is closed. + - google.auth.exceptions.TimeoutError: If the request times out. + """ + + try: + if self._closed: + raise exceptions.TransportError("session is closed.") + + if not self._session: + self._session = aiohttp.ClientSession() + + client_timeout = aiohttp.ClientTimeout(total=timeout) + _helpers.request_log(_LOGGER, method, url, body, headers) + response = await self._session.request( + method, + url, + data=body, + headers=headers, + timeout=client_timeout, + **kwargs, + ) + await _helpers_async.response_log_async(_LOGGER, response) + return Response(response) + + except aiohttp.ClientError as caught_exc: + client_exc = exceptions.TransportError(f"Failed to send request to {url}.") + raise client_exc from caught_exc + + except asyncio.TimeoutError as caught_exc: + timeout_exc = exceptions.TimeoutError( + f"Request timed out after {timeout} seconds." + ) + raise timeout_exc from caught_exc + + async def close(self) -> None: + """ + Close the underlying aiohttp session to release the acquired resources. + """ + if not self._closed and self._session: + await self._session.close() + self._closed = True diff --git a/packages/google-auth/google/auth/aio/transport/sessions.py b/packages/google-auth/google/auth/aio/transport/sessions.py new file mode 100644 index 000000000000..fea7cbbb2c3d --- /dev/null +++ b/packages/google-auth/google/auth/aio/transport/sessions.py @@ -0,0 +1,268 @@ +# Copyright 2024 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 asyncio +from contextlib import asynccontextmanager +import functools +import time +from typing import Mapping, Optional + +from google.auth import _exponential_backoff, exceptions +from google.auth.aio import transport +from google.auth.aio.credentials import Credentials +from google.auth.exceptions import TimeoutError + +try: + from google.auth.aio.transport.aiohttp import Request as AiohttpRequest + + AIOHTTP_INSTALLED = True +except ImportError: # pragma: NO COVER + AIOHTTP_INSTALLED = False + + +@asynccontextmanager +async def timeout_guard(timeout): + """ + timeout_guard is an asynchronous context manager to apply a timeout to an asynchronous block of code. + + Args: + timeout (float): The time in seconds before the context manager times out. + + Raises: + google.auth.exceptions.TimeoutError: If the code within the context exceeds the provided timeout. + + Usage: + async with timeout_guard(10) as with_timeout: + await with_timeout(async_function()) + """ + start = time.monotonic() + total_timeout = timeout + + def _remaining_time(): + elapsed = time.monotonic() - start + remaining = total_timeout - elapsed + if remaining <= 0: + raise TimeoutError( + f"Context manager exceeded the configured timeout of {total_timeout}s." + ) + return remaining + + async def with_timeout(coro): + try: + remaining = _remaining_time() + response = await asyncio.wait_for(coro, remaining) + return response + except (asyncio.TimeoutError, TimeoutError) as e: + raise TimeoutError( + f"The operation {coro} exceeded the configured timeout of {total_timeout}s." + ) from e + + try: + yield with_timeout + + finally: + _remaining_time() + + +class AsyncAuthorizedSession: + """This is an asynchronous implementation of :class:`google.auth.requests.AuthorizedSession` class. + We utilize an instance of a class that implements :class:`google.auth.aio.transport.Request` configured + by the caller or otherwise default to `google.auth.aio.transport.aiohttp.Request` if the external aiohttp + package is installed. + + A Requests Session class with credentials. + + This class is used to perform asynchronous requests to API endpoints that require + authorization:: + + import aiohttp + from google.auth.aio.transport import sessions + + async with sessions.AsyncAuthorizedSession(credentials) as authed_session: + response = await authed_session.request( + 'GET', 'https://www.googleapis.com/storage/v1/b') + + The underlying :meth:`request` implementation handles adding the + credentials' headers to the request and refreshing credentials as needed. + + Args: + credentials (google.auth.aio.credentials.Credentials): + The credentials to add to the request. + auth_request (Optional[google.auth.aio.transport.Request]): + An instance of a class that implements + :class:`~google.auth.aio.transport.Request` used to make requests + and refresh credentials. If not passed, + an instance of :class:`~google.auth.aio.transport.aiohttp.Request` + is created. + + Raises: + - google.auth.exceptions.TransportError: If `auth_request` is `None` + and the external package `aiohttp` is not installed. + - google.auth.exceptions.InvalidType: If the provided credentials are + not of type `google.auth.aio.credentials.Credentials`. + """ + + def __init__( + self, credentials: Credentials, auth_request: Optional[transport.Request] = None + ): + if not isinstance(credentials, Credentials): + raise exceptions.InvalidType( + f"The configured credentials of type {type(credentials)} are invalid and must be of type `google.auth.aio.credentials.Credentials`" + ) + self._credentials = credentials + _auth_request = auth_request + if not _auth_request and AIOHTTP_INSTALLED: + _auth_request = AiohttpRequest() + if _auth_request is None: + raise exceptions.TransportError( + "`auth_request` must either be configured or the external package `aiohttp` must be installed to use the default value." + ) + self._auth_request = _auth_request + + async def request( + self, + method: str, + url: str, + data: Optional[bytes] = None, + headers: Optional[Mapping[str, str]] = None, + max_allowed_time: float = transport._DEFAULT_TIMEOUT_SECONDS, + timeout: float = transport._DEFAULT_TIMEOUT_SECONDS, + **kwargs, + ) -> transport.Response: + """ + Args: + method (str): The http method used to make the request. + url (str): The URI to be requested. + data (Optional[bytes]): The payload or body in HTTP request. + headers (Optional[Mapping[str, str]]): Request headers. + timeout (float): + The amount of time in seconds to wait for the server response + with each individual request. + max_allowed_time (float): + If the method runs longer than this, a ``Timeout`` exception is + automatically raised. Unlike the ``timeout`` parameter, this + value applies to the total method execution time, even if + multiple requests are made under the hood. + + Mind that it is not guaranteed that the timeout error is raised + at ``max_allowed_time``. It might take longer, for example, if + an underlying request takes a lot of time, but the request + itself does not timeout, e.g. if a large file is being + transmitted. The timout error will be raised after such + request completes. + + Returns: + google.auth.aio.transport.Response: The HTTP response. + + Raises: + google.auth.exceptions.TimeoutError: If the method does not complete within + the configured `max_allowed_time` or the request exceeds the configured + `timeout`. + """ + + retries = _exponential_backoff.AsyncExponentialBackoff( + total_attempts=transport.DEFAULT_MAX_RETRY_ATTEMPTS + ) + async with timeout_guard(max_allowed_time) as with_timeout: + await with_timeout( + # Note: before_request will attempt to refresh credentials if expired. + self._credentials.before_request( + self._auth_request, method, url, headers + ) + ) + # Workaround issue in python 3.9 related to code coverage by adding `# pragma: no branch` + # See https://github.com/googleapis/gapic-generator-python/pull/1174#issuecomment-1025132372 + async for _ in retries: # pragma: no branch + response = await with_timeout( + self._auth_request(url, method, data, headers, timeout, **kwargs) + ) + if response.status_code not in transport.DEFAULT_RETRYABLE_STATUS_CODES: + break + return response + + @functools.wraps(request) + async def get( + self, + url: str, + data: Optional[bytes] = None, + headers: Optional[Mapping[str, str]] = None, + max_allowed_time: float = transport._DEFAULT_TIMEOUT_SECONDS, + timeout: float = transport._DEFAULT_TIMEOUT_SECONDS, + **kwargs, + ) -> transport.Response: + return await self.request( + "GET", url, data, headers, max_allowed_time, timeout, **kwargs + ) + + @functools.wraps(request) + async def post( + self, + url: str, + data: Optional[bytes] = None, + headers: Optional[Mapping[str, str]] = None, + max_allowed_time: float = transport._DEFAULT_TIMEOUT_SECONDS, + timeout: float = transport._DEFAULT_TIMEOUT_SECONDS, + **kwargs, + ) -> transport.Response: + return await self.request( + "POST", url, data, headers, max_allowed_time, timeout, **kwargs + ) + + @functools.wraps(request) + async def put( + self, + url: str, + data: Optional[bytes] = None, + headers: Optional[Mapping[str, str]] = None, + max_allowed_time: float = transport._DEFAULT_TIMEOUT_SECONDS, + timeout: float = transport._DEFAULT_TIMEOUT_SECONDS, + **kwargs, + ) -> transport.Response: + return await self.request( + "PUT", url, data, headers, max_allowed_time, timeout, **kwargs + ) + + @functools.wraps(request) + async def patch( + self, + url: str, + data: Optional[bytes] = None, + headers: Optional[Mapping[str, str]] = None, + max_allowed_time: float = transport._DEFAULT_TIMEOUT_SECONDS, + timeout: float = transport._DEFAULT_TIMEOUT_SECONDS, + **kwargs, + ) -> transport.Response: + return await self.request( + "PATCH", url, data, headers, max_allowed_time, timeout, **kwargs + ) + + @functools.wraps(request) + async def delete( + self, + url: str, + data: Optional[bytes] = None, + headers: Optional[Mapping[str, str]] = None, + max_allowed_time: float = transport._DEFAULT_TIMEOUT_SECONDS, + timeout: float = transport._DEFAULT_TIMEOUT_SECONDS, + **kwargs, + ) -> transport.Response: + return await self.request( + "DELETE", url, data, headers, max_allowed_time, timeout, **kwargs + ) + + async def close(self) -> None: + """ + Close the underlying auth request session. + """ + await self._auth_request.close() diff --git a/packages/google-auth/google/auth/api_key.py b/packages/google-auth/google/auth/api_key.py new file mode 100644 index 000000000000..4fdf7f2769ca --- /dev/null +++ b/packages/google-auth/google/auth/api_key.py @@ -0,0 +1,76 @@ +# 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. + +"""Google API key support. +This module provides authentication using the `API key`_. +.. _API key: + https://cloud.google.com/docs/authentication/api-keys/ +""" + +from google.auth import _helpers +from google.auth import credentials +from google.auth import exceptions + + +class Credentials(credentials.Credentials): + """API key credentials. + These credentials use API key to provide authorization to applications. + """ + + def __init__(self, token): + """ + Args: + token (str): API key string + Raises: + ValueError: If the provided API key is not a non-empty string. + """ + super(Credentials, self).__init__() + if not token: + raise exceptions.InvalidValue("Token must be a non-empty API key string") + self.token = token + + @property + def expired(self): + return False + + @property + def valid(self): + return True + + @_helpers.copy_docstring(credentials.Credentials) + def refresh(self, request): + return + + def apply(self, headers, token=None): + """Apply the API key token to the x-goog-api-key header. + Args: + headers (Mapping): The HTTP request headers. + token (Optional[str]): If specified, overrides the current access + token. + """ + headers["x-goog-api-key"] = token or self.token + + def before_request(self, request, method, url, headers): + """Performs credential-specific before request logic. + Refreshes the credentials if necessary, then calls :meth:`apply` to + apply the token to the x-goog-api-key header. + Args: + request (google.auth.transport.Request): The object used to make + HTTP requests. + method (str): The request's HTTP method or the RPC method being + invoked. + url (str): The request's URI or the RPC service's URI. + headers (Mapping): The request's headers. + """ + self.apply(headers) diff --git a/packages/google-auth/google/auth/app_engine.py b/packages/google-auth/google/auth/app_engine.py new file mode 100644 index 000000000000..7083ee61436b --- /dev/null +++ b/packages/google-auth/google/auth/app_engine.py @@ -0,0 +1,180 @@ +# Copyright 2016 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. + +"""Google App Engine standard environment support. + +This module provides authentication and signing for applications running on App +Engine in the standard environment using the `App Identity API`_. + + +.. _App Identity API: + https://cloud.google.com/appengine/docs/python/appidentity/ +""" + +import datetime + +from google.auth import _helpers +from google.auth import credentials +from google.auth import crypt +from google.auth import exceptions + +# pytype: disable=import-error +try: + from google.appengine.api import app_identity # type: ignore +except ImportError: + app_identity = None # type: ignore +# pytype: enable=import-error + + +class Signer(crypt.Signer): + """Signs messages using the App Engine App Identity service. + + This can be used in place of :class:`google.auth.crypt.Signer` when + running in the App Engine standard environment. + """ + + @property + def key_id(self): + """Optional[str]: The key ID used to identify this private key. + + .. warning:: + This is always ``None``. The key ID used by App Engine can not + be reliably determined ahead of time. + """ + return None + + @_helpers.copy_docstring(crypt.Signer) + def sign(self, message): + message = _helpers.to_bytes(message) + _, signature = app_identity.sign_blob(message) + return signature + + +def get_project_id(): + """Gets the project ID for the current App Engine application. + + Returns: + str: The project ID + + Raises: + google.auth.exceptions.OSError: If the App Engine APIs are unavailable. + """ + # pylint: disable=missing-raises-doc + # Pylint rightfully thinks google.auth.exceptions.OSError is OSError, but doesn't + # realize it's a valid alias. + if app_identity is None: + raise exceptions.OSError("The App Engine APIs are not available.") + return app_identity.get_application_id() + + +class Credentials( + credentials.Scoped, credentials.Signing, credentials.CredentialsWithQuotaProject +): + """App Engine standard environment credentials. + + These credentials use the App Engine App Identity API to obtain access + tokens. + """ + + def __init__( + self, + scopes=None, + default_scopes=None, + service_account_id=None, + quota_project_id=None, + ): + """ + Args: + scopes (Sequence[str]): Scopes to request from the App Identity + API. + default_scopes (Sequence[str]): Default scopes passed by a + Google client library. Use 'scopes' for user-defined scopes. + service_account_id (str): The service account ID passed into + :func:`google.appengine.api.app_identity.get_access_token`. + If not specified, the default application service account + ID will be used. + quota_project_id (Optional[str]): The project ID used for quota + and billing. + + Raises: + google.auth.exceptions.OSError: If the App Engine APIs are unavailable. + """ + # pylint: disable=missing-raises-doc + # Pylint rightfully thinks google.auth.exceptions.OSError is OSError, but doesn't + # realize it's a valid alias. + if app_identity is None: + raise exceptions.OSError("The App Engine APIs are not available.") + + super(Credentials, self).__init__() + self._scopes = scopes + self._default_scopes = default_scopes + self._service_account_id = service_account_id + self._signer = Signer() + self._quota_project_id = quota_project_id + + @_helpers.copy_docstring(credentials.Credentials) + def refresh(self, request): + scopes = self._scopes if self._scopes is not None else self._default_scopes + # pylint: disable=unused-argument + token, ttl = app_identity.get_access_token(scopes, self._service_account_id) + expiry = datetime.datetime.utcfromtimestamp(ttl) + + self.token, self.expiry = token, expiry + + @property + def service_account_email(self): + """The service account email.""" + if self._service_account_id is None: + self._service_account_id = app_identity.get_service_account_name() + return self._service_account_id + + @property + def requires_scopes(self): + """Checks if the credentials requires scopes. + + Returns: + bool: True if there are no scopes set otherwise False. + """ + return not self._scopes and not self._default_scopes + + @_helpers.copy_docstring(credentials.Scoped) + def with_scopes(self, scopes, default_scopes=None): + return self.__class__( + scopes=scopes, + default_scopes=default_scopes, + service_account_id=self._service_account_id, + quota_project_id=self.quota_project_id, + ) + + @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) + def with_quota_project(self, quota_project_id): + return self.__class__( + scopes=self._scopes, + service_account_id=self._service_account_id, + quota_project_id=quota_project_id, + ) + + @_helpers.copy_docstring(credentials.Signing) + def sign_bytes(self, message): + return self._signer.sign(message) + + @property # type: ignore + @_helpers.copy_docstring(credentials.Signing) + def signer_email(self): + return self.service_account_email + + @property # type: ignore + @_helpers.copy_docstring(credentials.Signing) + def signer(self): + return self._signer diff --git a/packages/google-auth/google/auth/aws.py b/packages/google-auth/google/auth/aws.py new file mode 100644 index 000000000000..8e6c6789c98e --- /dev/null +++ b/packages/google-auth/google/auth/aws.py @@ -0,0 +1,862 @@ +# 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. + +"""AWS Credentials and AWS Signature V4 Request Signer. + +This module provides credentials to access Google Cloud resources from Amazon +Web Services (AWS) workloads. These credentials are recommended over the +use of service account credentials in AWS as they do not involve the management +of long-live service account private keys. + +AWS Credentials are initialized using external_account arguments which are +typically loaded from the external credentials JSON file. + +This module also provides a definition for an abstract AWS security credentials supplier. +This supplier can be implemented to return valid AWS security credentials and an AWS region +and used to create AWS credentials. The credentials will then call the +supplier instead of using pre-defined methods such as calling the EC2 metadata endpoints. + +This module also provides a basic implementation of the +`AWS Signature Version 4`_ request signing algorithm. + +AWS Credentials use serialized signed requests to the +`AWS STS GetCallerIdentity`_ API that can be exchanged for Google access tokens +via the GCP STS endpoint. + +.. _AWS Signature Version 4: https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html +.. _AWS STS GetCallerIdentity: https://docs.aws.amazon.com/STS/latest/APIReference/API_GetCallerIdentity.html +""" + +import abc +from dataclasses import dataclass +import hashlib +import hmac +import http.client as http_client +import json +import os +import posixpath +import re +from typing import Optional +import urllib +from urllib.parse import urljoin + +from google.auth import _helpers +from google.auth import environment_vars +from google.auth import exceptions +from google.auth import external_account + +# AWS Signature Version 4 signing algorithm identifier. +_AWS_ALGORITHM = "AWS4-HMAC-SHA256" +# The termination string for the AWS credential scope value as defined in +# https://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html +_AWS_REQUEST_TYPE = "aws4_request" +# The AWS authorization header name for the security session token if available. +_AWS_SECURITY_TOKEN_HEADER = "x-amz-security-token" +# The AWS authorization header name for the auto-generated date. +_AWS_DATE_HEADER = "x-amz-date" +# The default AWS regional credential verification URL. +_DEFAULT_AWS_REGIONAL_CREDENTIAL_VERIFICATION_URL = ( + "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15" +) +# IMDSV2 session token lifetime. This is set to a low value because the session token is used immediately. +_IMDSV2_SESSION_TOKEN_TTL_SECONDS = "300" + + +class RequestSigner(object): + """Implements an AWS request signer based on the AWS Signature Version 4 signing + process. + https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html + """ + + def __init__(self, region_name): + """Instantiates an AWS request signer used to compute authenticated signed + requests to AWS APIs based on the AWS Signature Version 4 signing process. + + Args: + region_name (str): The AWS region to use. + """ + + self._region_name = region_name + + def get_request_options( + self, + aws_security_credentials, + url, + method, + request_payload="", + additional_headers={}, + ): + """Generates the signed request for the provided HTTP request for calling + an AWS API. This follows the steps described at: + https://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html + + Args: + aws_security_credentials (AWSSecurityCredentials): The AWS security credentials. + url (str): The AWS service URL containing the canonical URI and + query string. + method (str): The HTTP method used to call this API. + request_payload (Optional[str]): The optional request payload if + available. + additional_headers (Optional[Mapping[str, str]]): The optional + additional headers needed for the requested AWS API. + + Returns: + Mapping[str, str]: The AWS signed request dictionary object. + """ + + additional_headers = additional_headers or {} + + uri = urllib.parse.urlparse(url) + # Normalize the URL path. This is needed for the canonical_uri. + # os.path.normpath can't be used since it normalizes "/" paths + # to "\\" in Windows OS. + normalized_uri = urllib.parse.urlparse( + urljoin(url, posixpath.normpath(uri.path)) + ) + # Validate provided URL. + if not uri.hostname or uri.scheme != "https": + raise exceptions.InvalidResource("Invalid AWS service URL") + + header_map = _generate_authentication_header_map( + host=uri.hostname, + canonical_uri=normalized_uri.path or "/", + canonical_querystring=_get_canonical_querystring(uri.query), + method=method, + region=self._region_name, + aws_security_credentials=aws_security_credentials, + request_payload=request_payload, + additional_headers=additional_headers, + ) + headers = { + "Authorization": header_map.get("authorization_header"), + "host": uri.hostname, + } + # Add x-amz-date if available. + if "amz_date" in header_map: + headers[_AWS_DATE_HEADER] = header_map.get("amz_date") + # Append additional optional headers, eg. X-Amz-Target, Content-Type, etc. + for key in additional_headers: + headers[key] = additional_headers[key] + + # Add session token if available. + if aws_security_credentials.session_token is not None: + headers[_AWS_SECURITY_TOKEN_HEADER] = aws_security_credentials.session_token + + signed_request = {"url": url, "method": method, "headers": headers} + if request_payload: + signed_request["data"] = request_payload + return signed_request + + +def _get_canonical_querystring(query): + """Generates the canonical query string given a raw query string. + Logic is based on + https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html + + Args: + query (str): The raw query string. + + Returns: + str: The canonical query string. + """ + # Parse raw query string. + querystring = urllib.parse.parse_qs(query) + querystring_encoded_map = {} + for key in querystring: + quote_key = urllib.parse.quote(key, safe="-_.~") + # URI encode key. + querystring_encoded_map[quote_key] = [] + for item in querystring[key]: + # For each key, URI encode all values for that key. + querystring_encoded_map[quote_key].append( + urllib.parse.quote(item, safe="-_.~") + ) + # Sort values for each key. + querystring_encoded_map[quote_key].sort() + # Sort keys. + sorted_keys = list(querystring_encoded_map.keys()) + sorted_keys.sort() + # Reconstruct the query string. Preserve keys with multiple values. + querystring_encoded_pairs = [] + for key in sorted_keys: + for item in querystring_encoded_map[key]: + querystring_encoded_pairs.append("{}={}".format(key, item)) + return "&".join(querystring_encoded_pairs) + + +def _sign(key, msg): + """Creates the HMAC-SHA256 hash of the provided message using the provided + key. + + Args: + key (str): The HMAC-SHA256 key to use. + msg (str): The message to hash. + + Returns: + str: The computed hash bytes. + """ + return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest() + + +def _get_signing_key(key, date_stamp, region_name, service_name): + """Calculates the signing key used to calculate the signature for + AWS Signature Version 4 based on: + https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html + + Args: + key (str): The AWS secret access key. + date_stamp (str): The '%Y%m%d' date format. + region_name (str): The AWS region. + service_name (str): The AWS service name, eg. sts. + + Returns: + str: The signing key bytes. + """ + k_date = _sign(("AWS4" + key).encode("utf-8"), date_stamp) + k_region = _sign(k_date, region_name) + k_service = _sign(k_region, service_name) + k_signing = _sign(k_service, "aws4_request") + return k_signing + + +def _generate_authentication_header_map( + host, + canonical_uri, + canonical_querystring, + method, + region, + aws_security_credentials, + request_payload="", + additional_headers={}, +): + """Generates the authentication header map needed for generating the AWS + Signature Version 4 signed request. + + Args: + host (str): The AWS service URL hostname. + canonical_uri (str): The AWS service URL path name. + canonical_querystring (str): The AWS service URL query string. + method (str): The HTTP method used to call this API. + region (str): The AWS region. + aws_security_credentials (AWSSecurityCredentials): The AWS security credentials. + request_payload (Optional[str]): The optional request payload if + available. + additional_headers (Optional[Mapping[str, str]]): The optional + additional headers needed for the requested AWS API. + + Returns: + Mapping[str, str]: The AWS authentication header dictionary object. + This contains the x-amz-date and authorization header information. + """ + # iam.amazonaws.com host => iam service. + # sts.us-east-2.amazonaws.com host => sts service. + service_name = host.split(".")[0] + + current_time = _helpers.utcnow() + amz_date = current_time.strftime("%Y%m%dT%H%M%SZ") + date_stamp = current_time.strftime("%Y%m%d") + + # Change all additional headers to be lower case. + full_headers = {} + for key in additional_headers: + full_headers[key.lower()] = additional_headers[key] + # Add AWS session token if available. + if aws_security_credentials.session_token is not None: + full_headers[ + _AWS_SECURITY_TOKEN_HEADER + ] = aws_security_credentials.session_token + + # Required headers + full_headers["host"] = host + # Do not use generated x-amz-date if the date header is provided. + # Previously the date was not fixed with x-amz- and could be provided + # manually. + # https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-header-value-trim.req + if "date" not in full_headers: + full_headers[_AWS_DATE_HEADER] = amz_date + + # Header keys need to be sorted alphabetically. + canonical_headers = "" + header_keys = list(full_headers.keys()) + header_keys.sort() + for key in header_keys: + canonical_headers = "{}{}:{}\n".format( + canonical_headers, key, full_headers[key] + ) + signed_headers = ";".join(header_keys) + + payload_hash = hashlib.sha256((request_payload or "").encode("utf-8")).hexdigest() + + # https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html + canonical_request = "{}\n{}\n{}\n{}\n{}\n{}".format( + method, + canonical_uri, + canonical_querystring, + canonical_headers, + signed_headers, + payload_hash, + ) + + credential_scope = "{}/{}/{}/{}".format( + date_stamp, region, service_name, _AWS_REQUEST_TYPE + ) + + # https://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html + string_to_sign = "{}\n{}\n{}\n{}".format( + _AWS_ALGORITHM, + amz_date, + credential_scope, + hashlib.sha256(canonical_request.encode("utf-8")).hexdigest(), + ) + + # https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html + signing_key = _get_signing_key( + aws_security_credentials.secret_access_key, date_stamp, region, service_name + ) + signature = hmac.new( + signing_key, string_to_sign.encode("utf-8"), hashlib.sha256 + ).hexdigest() + + # https://docs.aws.amazon.com/general/latest/gr/sigv4-add-signature-to-request.html + authorization_header = "{} Credential={}/{}, SignedHeaders={}, Signature={}".format( + _AWS_ALGORITHM, + aws_security_credentials.access_key_id, + credential_scope, + signed_headers, + signature, + ) + + authentication_header = {"authorization_header": authorization_header} + # Do not use generated x-amz-date if the date header is provided. + if "date" not in full_headers: + authentication_header["amz_date"] = amz_date + return authentication_header + + +@dataclass +class AwsSecurityCredentials: + """A class that models AWS security credentials with an optional session token. + + Attributes: + access_key_id (str): The AWS security credentials access key id. + secret_access_key (str): The AWS security credentials secret access key. + session_token (Optional[str]): The optional AWS security credentials session token. This should be set when using temporary credentials. + """ + + access_key_id: str + secret_access_key: str + session_token: Optional[str] = None + + +class AwsSecurityCredentialsSupplier(metaclass=abc.ABCMeta): + """Base class for AWS security credential suppliers. This can be implemented with custom logic to retrieve + AWS security credentials to exchange for a Google Cloud access token. The AWS external account credential does + not cache the AWS security credentials, so caching logic should be added in the implementation. + """ + + @abc.abstractmethod + def get_aws_security_credentials(self, context, request): + """Returns the AWS security credentials for the requested context. + + .. warning: This is not cached by the calling Google credential, so caching logic should be implemented in the supplier. + + Args: + context (google.auth.externalaccount.SupplierContext): The context object + containing information about the requested audience and subject token type. + request (google.auth.transport.Request): The object used to make + HTTP requests. + + Raises: + google.auth.exceptions.RefreshError: If an error is encountered during + security credential retrieval logic. + + Returns: + AwsSecurityCredentials: The requested AWS security credentials. + """ + raise NotImplementedError("") + + @abc.abstractmethod + def get_aws_region(self, context, request): + """Returns the AWS region for the requested context. + + Args: + context (google.auth.externalaccount.SupplierContext): The context object + containing information about the requested audience and subject token type. + request (google.auth.transport.Request): The object used to make + HTTP requests. + + Raises: + google.auth.exceptions.RefreshError: If an error is encountered during + region retrieval logic. + + Returns: + str: The AWS region. + """ + raise NotImplementedError("") + + +class _DefaultAwsSecurityCredentialsSupplier(AwsSecurityCredentialsSupplier): + """Default implementation of AWS security credentials supplier. Supports retrieving + credentials and region via EC2 metadata endpoints and environment variables. + """ + + def __init__(self, credential_source): + self._region_url = credential_source.get("region_url") + self._security_credentials_url = credential_source.get("url") + self._imdsv2_session_token_url = credential_source.get( + "imdsv2_session_token_url" + ) + + @_helpers.copy_docstring(AwsSecurityCredentialsSupplier) + def get_aws_security_credentials(self, context, request): + # Check environment variables for permanent credentials first. + # https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html + env_aws_access_key_id = os.environ.get(environment_vars.AWS_ACCESS_KEY_ID) + env_aws_secret_access_key = os.environ.get( + environment_vars.AWS_SECRET_ACCESS_KEY + ) + # This is normally not available for permanent credentials. + env_aws_session_token = os.environ.get(environment_vars.AWS_SESSION_TOKEN) + if env_aws_access_key_id and env_aws_secret_access_key: + return AwsSecurityCredentials( + env_aws_access_key_id, env_aws_secret_access_key, env_aws_session_token + ) + + imdsv2_session_token = self._get_imdsv2_session_token(request) + role_name = self._get_metadata_role_name(request, imdsv2_session_token) + + # Get security credentials. + credentials = self._get_metadata_security_credentials( + request, role_name, imdsv2_session_token + ) + + return AwsSecurityCredentials( + credentials.get("AccessKeyId"), + credentials.get("SecretAccessKey"), + credentials.get("Token"), + ) + + @_helpers.copy_docstring(AwsSecurityCredentialsSupplier) + def get_aws_region(self, context, request): + # The AWS metadata server is not available in some AWS environments + # such as AWS lambda. Instead, it is available via environment + # variable. + env_aws_region = os.environ.get(environment_vars.AWS_REGION) + if env_aws_region is not None: + return env_aws_region + + env_aws_region = os.environ.get(environment_vars.AWS_DEFAULT_REGION) + if env_aws_region is not None: + return env_aws_region + + if not self._region_url: + raise exceptions.RefreshError("Unable to determine AWS region") + + headers = None + imdsv2_session_token = self._get_imdsv2_session_token(request) + if imdsv2_session_token is not None: + headers = {"X-aws-ec2-metadata-token": imdsv2_session_token} + + response = request(url=self._region_url, method="GET", headers=headers) + + # Support both string and bytes type response.data. + response_body = ( + response.data.decode("utf-8") + if hasattr(response.data, "decode") + else response.data + ) + + if response.status != http_client.OK: + raise exceptions.RefreshError( + "Unable to retrieve AWS region: {}".format(response_body) + ) + + # This endpoint will return the region in format: us-east-2b. + # Only the us-east-2 part should be used. + return response_body[:-1] + + def _get_imdsv2_session_token(self, request): + if request is not None and self._imdsv2_session_token_url is not None: + headers = { + "X-aws-ec2-metadata-token-ttl-seconds": _IMDSV2_SESSION_TOKEN_TTL_SECONDS + } + + imdsv2_session_token_response = request( + url=self._imdsv2_session_token_url, method="PUT", headers=headers + ) + + if imdsv2_session_token_response.status != http_client.OK: + raise exceptions.RefreshError( + "Unable to retrieve AWS Session Token: {}".format( + imdsv2_session_token_response.data + ) + ) + + return imdsv2_session_token_response.data + else: + return None + + def _get_metadata_security_credentials( + self, request, role_name, imdsv2_session_token + ): + """Retrieves the AWS security credentials required for signing AWS + requests from the AWS metadata server. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + role_name (str): The AWS role name required by the AWS metadata + server security_credentials endpoint in order to return the + credentials. + imdsv2_session_token (str): The AWS IMDSv2 session token to be added as a + header in the requests to AWS metadata endpoint. + + Returns: + Mapping[str, str]: The AWS metadata server security credentials + response. + + Raises: + google.auth.exceptions.RefreshError: If an error occurs while + retrieving the AWS security credentials. + """ + headers = {"Content-Type": "application/json"} + if imdsv2_session_token is not None: + headers["X-aws-ec2-metadata-token"] = imdsv2_session_token + + response = request( + url="{}/{}".format(self._security_credentials_url, role_name), + method="GET", + headers=headers, + ) + + # support both string and bytes type response.data + response_body = ( + response.data.decode("utf-8") + if hasattr(response.data, "decode") + else response.data + ) + + if response.status != http_client.OK: + raise exceptions.RefreshError( + "Unable to retrieve AWS security credentials: {}".format(response_body) + ) + + credentials_response = json.loads(response_body) + + return credentials_response + + def _get_metadata_role_name(self, request, imdsv2_session_token): + """Retrieves the AWS role currently attached to the current AWS + workload by querying the AWS metadata server. This is needed for the + AWS metadata server security credentials endpoint in order to retrieve + the AWS security credentials needed to sign requests to AWS APIs. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + imdsv2_session_token (str): The AWS IMDSv2 session token to be added as a + header in the requests to AWS metadata endpoint. + + Returns: + str: The AWS role name. + + Raises: + google.auth.exceptions.RefreshError: If an error occurs while + retrieving the AWS role name. + """ + if self._security_credentials_url is None: + raise exceptions.RefreshError( + "Unable to determine the AWS metadata server security credentials endpoint" + ) + + headers = None + if imdsv2_session_token is not None: + headers = {"X-aws-ec2-metadata-token": imdsv2_session_token} + + response = request( + url=self._security_credentials_url, method="GET", headers=headers + ) + + # support both string and bytes type response.data + response_body = ( + response.data.decode("utf-8") + if hasattr(response.data, "decode") + else response.data + ) + + if response.status != http_client.OK: + raise exceptions.RefreshError( + "Unable to retrieve AWS role name {}".format(response_body) + ) + + return response_body + + +class Credentials(external_account.Credentials): + """AWS external account credentials. + This is used to exchange serialized AWS signature v4 signed requests to + AWS STS GetCallerIdentity service for Google access tokens. + """ + + def __init__( + self, + audience, + subject_token_type, + token_url=external_account._DEFAULT_TOKEN_URL, + credential_source=None, + aws_security_credentials_supplier=None, + *args, + **kwargs + ): + """Instantiates an AWS workload external account credentials object. + + Args: + audience (str): The STS audience field. + subject_token_type (str): The subject token type based on the Oauth2.0 token exchange spec. + Expected values include:: + + “urn:ietf:params:aws:token-type:aws4_request” + + token_url (Optional [str]): The STS endpoint URL. If not provided, will default to "https://sts.googleapis.com/v1/token". + credential_source (Optional [Mapping]): The credential source dictionary used + to provide instructions on how to retrieve external credential to be exchanged for Google access tokens. + Either a credential source or an AWS security credentials supplier must be provided. + + Example credential_source for AWS credential:: + + { + "environment_id": "aws1", + "regional_cred_verification_url": "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15", + "region_url": "http://169.254.169.254/latest/meta-data/placement/availability-zone", + "url": "http://169.254.169.254/latest/meta-data/iam/security-credentials", + imdsv2_session_token_url": "http://169.254.169.254/latest/api/token" + } + + aws_security_credentials_supplier (Optional [AwsSecurityCredentialsSupplier]): Optional AWS security credentials supplier. + This will be called to supply valid AWS security credentails which will then + be exchanged for Google access tokens. Either an AWS security credentials supplier + or a credential source must be provided. + args (List): Optional positional arguments passed into the underlying :meth:`~external_account.Credentials.__init__` method. + kwargs (Mapping): Optional keyword arguments passed into the underlying :meth:`~external_account.Credentials.__init__` method. + + Raises: + google.auth.exceptions.RefreshError: If an error is encountered during + access token retrieval logic. + ValueError: For invalid parameters. + + .. note:: Typically one of the helper constructors + :meth:`from_file` or + :meth:`from_info` are used instead of calling the constructor directly. + """ + super(Credentials, self).__init__( + audience=audience, + subject_token_type=subject_token_type, + token_url=token_url, + credential_source=credential_source, + *args, + **kwargs + ) + if credential_source is None and aws_security_credentials_supplier is None: + raise exceptions.InvalidValue( + "A valid credential source or AWS security credentials supplier must be provided." + ) + if ( + credential_source is not None + and aws_security_credentials_supplier is not None + ): + raise exceptions.InvalidValue( + "AWS credential cannot have both a credential source and an AWS security credentials supplier." + ) + + if aws_security_credentials_supplier: + self._aws_security_credentials_supplier = aws_security_credentials_supplier + # The regional cred verification URL would normally be provided through the credential source. So set it to the default one here. + self._cred_verification_url = ( + _DEFAULT_AWS_REGIONAL_CREDENTIAL_VERIFICATION_URL + ) + else: + environment_id = credential_source.get("environment_id") or "" + self._aws_security_credentials_supplier = ( + _DefaultAwsSecurityCredentialsSupplier(credential_source) + ) + self._cred_verification_url = credential_source.get( + "regional_cred_verification_url" + ) + + # Get the environment ID, i.e. "aws1". Currently, only one version supported (1). + matches = re.match(r"^(aws)([\d]+)$", environment_id) + if matches: + env_id, env_version = matches.groups() + else: + env_id, env_version = (None, None) + + if env_id != "aws" or self._cred_verification_url is None: + raise exceptions.InvalidResource( + "No valid AWS 'credential_source' provided" + ) + elif env_version is None or int(env_version) != 1: + raise exceptions.InvalidValue( + "aws version '{}' is not supported in the current build.".format( + env_version + ) + ) + + self._target_resource = audience + self._request_signer = None + + 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`_. + + The logic is summarized as: + + Retrieve the AWS region from the AWS_REGION or AWS_DEFAULT_REGION + environment variable or from the AWS metadata server availability-zone + if not found in the environment variable. + + Check AWS credentials in environment variables. If not found, retrieve + from the AWS metadata server security-credentials endpoint. + + When retrieving AWS credentials from the metadata server + security-credentials endpoint, the AWS role needs to be determined by + calling the security-credentials endpoint without any argument. Then the + credentials can be retrieved via: security-credentials/role_name + + Generate the signed request to AWS STS GetCallerIdentity action. + + Inject x-goog-cloud-target-resource into header and serialize the + signed request. This will be the subject-token to pass to GCP STS. + + .. _AWS GetCallerIdentity signed request: + https://cloud.google.com/iam/docs/access-resources-aws#exchange-token + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + Returns: + str: The retrieved subject token. + """ + + # Initialize the request signer if not yet initialized after determining + # the current AWS region. + if self._request_signer is None: + self._region = self._aws_security_credentials_supplier.get_aws_region( + self._supplier_context, request + ) + self._request_signer = RequestSigner(self._region) + + # Retrieve the AWS security credentials needed to generate the signed + # request. + aws_security_credentials = ( + self._aws_security_credentials_supplier.get_aws_security_credentials( + self._supplier_context, request + ) + ) + # Generate the signed request to AWS STS GetCallerIdentity API. + # Use the required regional endpoint. Otherwise, the request will fail. + request_options = self._request_signer.get_request_options( + aws_security_credentials, + self._cred_verification_url.replace("{region}", self._region), + "POST", + ) + # The GCP STS endpoint expects the headers to be formatted as: + # [ + # {key: 'x-amz-date', value: '...'}, + # {key: 'Authorization', value: '...'}, + # ... + # ] + # And then serialized as: + # quote(json.dumps({ + # url: '...', + # method: 'POST', + # headers: [{key: 'x-amz-date', value: '...'}, ...] + # })) + request_headers = request_options.get("headers") + # The full, canonical resource name of the workload identity pool + # provider, with or without the HTTPS prefix. + # Including this header as part of the signature is recommended to + # ensure data integrity. + request_headers["x-goog-cloud-target-resource"] = self._target_resource + + # Serialize AWS signed request. + aws_signed_req = {} + aws_signed_req["url"] = request_options.get("url") + aws_signed_req["method"] = request_options.get("method") + aws_signed_req["headers"] = [] + # Reformat header to GCP STS expected format. + for key in request_headers.keys(): + aws_signed_req["headers"].append( + {"key": key, "value": request_headers[key]} + ) + + return urllib.parse.quote( + json.dumps(aws_signed_req, separators=(",", ":"), sort_keys=True) + ) + + def _create_default_metrics_options(self): + metrics_options = super(Credentials, self)._create_default_metrics_options() + metrics_options["source"] = "aws" + if self._has_custom_supplier(): + metrics_options["source"] = "programmatic" + return metrics_options + + def _has_custom_supplier(self): + return self._credential_source is None + + def _constructor_args(self): + args = super(Credentials, self)._constructor_args() + # If a custom supplier was used, append it to the args dict. + if self._has_custom_supplier(): + args.update( + { + "aws_security_credentials_supplier": self._aws_security_credentials_supplier + } + ) + return args + + @classmethod + def from_info(cls, info, **kwargs): + """Creates an AWS Credentials instance from parsed external account info. + + Args: + info (Mapping[str, str]): The AWS external account info in Google + format. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.aws.Credentials: The constructed credentials. + + Raises: + ValueError: For invalid parameters. + """ + aws_security_credentials_supplier = info.get( + "aws_security_credentials_supplier" + ) + kwargs.update( + {"aws_security_credentials_supplier": aws_security_credentials_supplier} + ) + return super(Credentials, cls).from_info(info, **kwargs) + + @classmethod + def from_file(cls, filename, **kwargs): + """Creates an AWS Credentials instance from an external account json file. + + Args: + filename (str): The path to the AWS external account json file. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.aws.Credentials: The constructed credentials. + """ + return super(Credentials, cls).from_file(filename, **kwargs) diff --git a/packages/google-auth/google/auth/compute_engine/__init__.py b/packages/google-auth/google/auth/compute_engine/__init__.py new file mode 100644 index 000000000000..7e1206fc1b28 --- /dev/null +++ b/packages/google-auth/google/auth/compute_engine/__init__.py @@ -0,0 +1,22 @@ +# Copyright 2016 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. + +"""Google Compute Engine authentication.""" + +from google.auth.compute_engine._metadata import detect_gce_residency_linux +from google.auth.compute_engine.credentials import Credentials +from google.auth.compute_engine.credentials import IDTokenCredentials + + +__all__ = ["Credentials", "IDTokenCredentials", "detect_gce_residency_linux"] diff --git a/packages/google-auth/google/auth/compute_engine/_metadata.py b/packages/google-auth/google/auth/compute_engine/_metadata.py new file mode 100644 index 000000000000..96f1ff526ef2 --- /dev/null +++ b/packages/google-auth/google/auth/compute_engine/_metadata.py @@ -0,0 +1,470 @@ +# Copyright 2016 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. + +"""Provides helper methods for talking to the Compute Engine metadata server. + +See https://cloud.google.com/compute/docs/metadata for more details. +""" + +import datetime +import http.client as http_client +import json +import logging +import os +from urllib.parse import urljoin + +import requests + +from google.auth import _helpers +from google.auth import environment_vars +from google.auth import exceptions +from google.auth import metrics +from google.auth import transport +from google.auth._exponential_backoff import ExponentialBackoff +from google.auth.compute_engine import _mtls + + +_LOGGER = logging.getLogger(__name__) + +_GCE_DEFAULT_MDS_IP = "169.254.169.254" +_GCE_DEFAULT_HOST = "metadata.google.internal" +_GCE_DEFAULT_MDS_HOSTS = [_GCE_DEFAULT_HOST, _GCE_DEFAULT_MDS_IP] + +# Environment variable GCE_METADATA_HOST is originally named +# GCE_METADATA_ROOT. For compatibility reasons, here it checks +# the new variable first; if not set, the system falls back +# to the old variable. +_GCE_METADATA_HOST = os.getenv(environment_vars.GCE_METADATA_HOST, None) +if not _GCE_METADATA_HOST: + _GCE_METADATA_HOST = os.getenv( + environment_vars.GCE_METADATA_ROOT, _GCE_DEFAULT_HOST + ) + + +def _validate_gce_mds_configured_environment(): + """Validates the GCE metadata server environment configuration for mTLS. + + mTLS is only supported when connecting to the default metadata server hosts. + If we are in strict mode (which requires mTLS), ensure that the metadata host + has not been overridden to a custom value (which means mTLS will fail). + + Raises: + google.auth.exceptions.MutualTLSChannelError: if the environment + configuration is invalid for mTLS. + """ + mode = _mtls._parse_mds_mode() + if mode == _mtls.MdsMtlsMode.STRICT: + # mTLS is only supported when connecting to the default metadata host. + # Raise an exception if we are in strict mode (which requires mTLS) + # but the metadata host has been overridden to a custom MDS. (which means mTLS will fail) + if _GCE_METADATA_HOST not in _GCE_DEFAULT_MDS_HOSTS: + raise exceptions.MutualTLSChannelError( + "Mutual TLS is required, but the metadata host has been overridden. " + "mTLS is only supported when connecting to the default metadata host." + ) + + +def _get_metadata_root(use_mtls: bool): + """Returns the metadata server root URL.""" + + scheme = "https" if use_mtls else "http" + return "{}://{}/computeMetadata/v1/".format(scheme, _GCE_METADATA_HOST) + + +def _get_metadata_ip_root(use_mtls: bool): + """Returns the metadata server IP root URL.""" + scheme = "https" if use_mtls else "http" + return "{}://{}".format( + scheme, os.getenv(environment_vars.GCE_METADATA_IP, _GCE_DEFAULT_MDS_IP) + ) + + +_METADATA_FLAVOR_HEADER = "metadata-flavor" +_METADATA_FLAVOR_VALUE = "Google" +_METADATA_HEADERS = {_METADATA_FLAVOR_HEADER: _METADATA_FLAVOR_VALUE} + +# Timeout in seconds to wait for the GCE metadata server when detecting the +# GCE environment. +try: + _METADATA_DEFAULT_TIMEOUT = int(os.getenv("GCE_METADATA_TIMEOUT", 3)) +except ValueError: # pragma: NO COVER + _METADATA_DEFAULT_TIMEOUT = 3 + +# Detect GCE Residency +_GOOGLE = "Google" +_GCE_PRODUCT_NAME_FILE = "/sys/class/dmi/id/product_name" + + +def is_on_gce(request): + """Checks to see if the code runs on Google Compute Engine + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + + Returns: + bool: True if the code runs on Google Compute Engine, False otherwise. + """ + if ping(request): + return True + + if os.name == "nt": + # TODO: implement GCE residency detection on Windows + return False + + # Detect GCE residency on Linux + return detect_gce_residency_linux() + + +def detect_gce_residency_linux(): + """Detect Google Compute Engine residency by smbios check on Linux + + Returns: + bool: True if the GCE product name file is detected, False otherwise. + """ + try: + with open(_GCE_PRODUCT_NAME_FILE, "r") as file_obj: + content = file_obj.read().strip() + + except Exception: + return False + + return content.startswith(_GOOGLE) + + +def _prepare_request_for_mds(request, use_mtls=False) -> None: + """Prepares a request for the metadata server. + + This will check if mTLS should be used and mount the mTLS adapter if needed. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + use_mtls (bool): Whether to use mTLS for the request. + + Returns: + google.auth.transport.Request: A request object to use. + If mTLS is enabled, the request will have the mTLS adapter mounted. + Otherwise, the original request will be returned unchanged. + """ + # Only modify the request if mTLS is enabled. + if use_mtls: + # Ensure the request has a session to mount the adapter to. + if not request.session: + request.session = requests.Session() + + adapter = _mtls.MdsMtlsAdapter() + # Mount the adapter for all default GCE metadata hosts. + for host in _GCE_DEFAULT_MDS_HOSTS: + request.session.mount(f"https://{host}/", adapter) + + +def ping(request, timeout=_METADATA_DEFAULT_TIMEOUT, retry_count=3): + """Checks to see if the metadata server is available. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + timeout (int): How long to wait for the metadata server to respond. + retry_count (int): How many times to attempt connecting to metadata + server using above timeout. + + Returns: + bool: True if the metadata server is reachable, False otherwise. + """ + use_mtls = _mtls.should_use_mds_mtls() + _prepare_request_for_mds(request, use_mtls=use_mtls) + # NOTE: The explicit ``timeout`` is a workaround. The underlying + # issue is that resolving an unknown host on some networks will take + # 20-30 seconds; making this timeout short fixes the issue, but + # could lead to false negatives in the event that we are on GCE, but + # the metadata resolution was particularly slow. The latter case is + # "unlikely". + headers = _METADATA_HEADERS.copy() + headers[metrics.API_CLIENT_HEADER] = metrics.mds_ping() + + backoff = ExponentialBackoff(total_attempts=retry_count) + + for attempt in backoff: + try: + response = request( + url=_get_metadata_ip_root(use_mtls), + method="GET", + headers=headers, + timeout=timeout, + ) + + metadata_flavor = response.headers.get(_METADATA_FLAVOR_HEADER) + return ( + response.status == http_client.OK + and metadata_flavor == _METADATA_FLAVOR_VALUE + ) + + except exceptions.TransportError as e: + _LOGGER.warning( + "Compute Engine Metadata server unavailable on " + "attempt %s of %s. Reason: %s", + attempt, + retry_count, + e, + ) + + return False + + +def get( + request, + path, + root=None, + params=None, + recursive=False, + retry_count=5, + headers=None, + return_none_for_not_found_error=False, + timeout=_METADATA_DEFAULT_TIMEOUT, +): + """Fetch a resource from the metadata server. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + path (str): The resource to retrieve. For example, + ``'instance/service-accounts/default'``. + root (Optional[str]): The full path to the metadata server root. If not + provided, the default root will be used. + params (Optional[Mapping[str, str]]): A mapping of query parameter + keys to values. + recursive (bool): Whether to do a recursive query of metadata. See + https://cloud.google.com/compute/docs/metadata#aggcontents for more + details. + retry_count (int): How many times to attempt connecting to metadata + server using above timeout. + headers (Optional[Mapping[str, str]]): Headers for the request. + return_none_for_not_found_error (Optional[bool]): If True, returns None + for 404 error instead of throwing an exception. + timeout (int): How long to wait, in seconds for the metadata server to respond. + + Returns: + Union[Mapping, str]: If the metadata server returns JSON, a mapping of + the decoded JSON is returned. Otherwise, the response content is + returned as a string. + + Raises: + google.auth.exceptions.TransportError: if an error occurred while + retrieving metadata. + google.auth.exceptions.MutualTLSChannelError: if using mtls and the environment + configuration is invalid for mTLS (for example, the metadata host + has been overridden in strict mTLS mode). + + """ + use_mtls = _mtls.should_use_mds_mtls() + # Prepare the request object for mTLS if needed. + # This will create a new request object with the mTLS session. + _prepare_request_for_mds(request, use_mtls=use_mtls) + + if root is None: + root = _get_metadata_root(use_mtls) + + # mTLS is only supported when connecting to the default metadata host. + # If we are in strict mode (which requires mTLS), ensure that the metadata host + # has not been overridden to a non-default host value (which means mTLS will fail). + _validate_gce_mds_configured_environment() + + base_url = urljoin(root, path) + query_params = {} if params is None else params + + headers_to_use = _METADATA_HEADERS.copy() + if headers: + headers_to_use.update(headers) + + if recursive: + query_params["recursive"] = "true" + + url = _helpers.update_query(base_url, query_params) + + backoff = ExponentialBackoff(total_attempts=retry_count) + failure_reason = None + for attempt in backoff: + try: + response = request( + url=url, method="GET", headers=headers_to_use, timeout=timeout + ) + if response.status in transport.DEFAULT_RETRYABLE_STATUS_CODES: + _LOGGER.warning( + "Compute Engine Metadata server unavailable on " + "attempt %s of %s. Response status: %s", + attempt, + retry_count, + response.status, + ) + failure_reason = ( + response.data.decode("utf-8") + if hasattr(response.data, "decode") + else response.data + ) + continue + else: + break + + except exceptions.TransportError as e: + _LOGGER.warning( + "Compute Engine Metadata server unavailable on " + "attempt %s of %s. Reason: %s", + attempt, + retry_count, + e, + ) + failure_reason = e + else: + raise exceptions.TransportError( + "Failed to retrieve {} from the Google Compute Engine " + "metadata service. Compute Engine Metadata server unavailable due to {}".format( + url, failure_reason + ) + ) + + content = _helpers.from_bytes(response.data) + + if response.status == http_client.NOT_FOUND and return_none_for_not_found_error: + return None + + if response.status == http_client.OK: + if ( + _helpers.parse_content_type(response.headers["content-type"]) + == "application/json" + ): + try: + return json.loads(content) + except ValueError as caught_exc: + new_exc = exceptions.TransportError( + "Received invalid JSON from the Google Compute Engine " + "metadata service: {:.20}".format(content) + ) + raise new_exc from caught_exc + else: + return content + + raise exceptions.TransportError( + "Failed to retrieve {} from the Google Compute Engine " + "metadata service. Status: {} Response:\n{}".format( + url, response.status, response.data + ), + response, + ) + + +def get_project_id(request): + """Get the Google Cloud Project ID from the metadata server. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + + Returns: + str: The project ID + + Raises: + google.auth.exceptions.TransportError: if an error occurred while + retrieving metadata. + """ + return get(request, "project/project-id") + + +def get_universe_domain(request): + """Get the universe domain value from the metadata server. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + + Returns: + str: The universe domain value. If the universe domain endpoint is not + not found, return the default value, which is googleapis.com + + Raises: + google.auth.exceptions.TransportError: if an error other than + 404 occurs while retrieving metadata. + """ + universe_domain = get( + request, "universe/universe-domain", return_none_for_not_found_error=True + ) + if not universe_domain: + return "googleapis.com" + return universe_domain + + +def get_service_account_info(request, service_account="default"): + """Get information about a service account from the metadata server. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + service_account (str): The string 'default' or a service account email + address. The determines which service account for which to acquire + information. + + Returns: + Mapping: The service account's information, for example:: + + { + 'email': '...', + 'scopes': ['scope', ...], + 'aliases': ['default', '...'] + } + + Raises: + google.auth.exceptions.TransportError: if an error occurred while + retrieving metadata. + """ + path = "instance/service-accounts/{0}/".format(service_account) + # See https://cloud.google.com/compute/docs/metadata#aggcontents + # for more on the use of 'recursive'. + return get(request, path, params={"recursive": "true"}) + + +def get_service_account_token(request, service_account="default", scopes=None): + """Get the OAuth 2.0 access token for a service account. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + service_account (str): The string 'default' or a service account email + address. The determines which service account for which to acquire + an access token. + scopes (Optional[Union[str, List[str]]]): Optional string or list of + strings with auth scopes. + Returns: + Tuple[str, datetime]: The access token and its expiration. + + Raises: + google.auth.exceptions.TransportError: if an error occurred while + retrieving metadata. + """ + if scopes: + if not isinstance(scopes, str): + scopes = ",".join(scopes) + params = {"scopes": scopes} + else: + params = None + + metrics_header = { + metrics.API_CLIENT_HEADER: metrics.token_request_access_token_mds() + } + + path = "instance/service-accounts/{0}/token".format(service_account) + token_json = get(request, path, params=params, headers=metrics_header) + token_expiry = _helpers.utcnow() + datetime.timedelta( + seconds=token_json["expires_in"] + ) + return token_json["access_token"], token_expiry diff --git a/packages/google-auth/google/auth/compute_engine/_mtls.py b/packages/google-auth/google/auth/compute_engine/_mtls.py new file mode 100644 index 000000000000..6525dd03e1bd --- /dev/null +++ b/packages/google-auth/google/auth/compute_engine/_mtls.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2024 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. +# +"""Mutual TLS for Google Compute Engine metadata server.""" + +from dataclasses import dataclass, field +import enum +import logging +import os +from pathlib import Path +import ssl +from urllib.parse import urlparse, urlunparse + +import requests +from requests.adapters import HTTPAdapter + +from google.auth import environment_vars, exceptions + + +_LOGGER = logging.getLogger(__name__) + +_WINDOWS_OS_NAME = "nt" + +# MDS mTLS certificate paths based on OS. +# Documentation to well known locations can be found at: +# https://cloud.google.com/compute/docs/metadata/overview#https-mds-certificates +_WINDOWS_MTLS_COMPONENTS_BASE_PATH = Path("C:/ProgramData/Google/ComputeEngine") +_MTLS_COMPONENTS_BASE_PATH = Path("/run/google-mds-mtls") + + +def _get_mds_root_crt_path(): + if os.name == _WINDOWS_OS_NAME: + return _WINDOWS_MTLS_COMPONENTS_BASE_PATH / "mds-mtls-root.crt" + else: + return _MTLS_COMPONENTS_BASE_PATH / "root.crt" + + +def _get_mds_client_combined_cert_path(): + if os.name == _WINDOWS_OS_NAME: + return _WINDOWS_MTLS_COMPONENTS_BASE_PATH / "mds-mtls-client.key" + else: + return _MTLS_COMPONENTS_BASE_PATH / "client.key" + + +@dataclass +class MdsMtlsConfig: + ca_cert_path: Path = field( + default_factory=_get_mds_root_crt_path + ) # path to CA certificate + client_combined_cert_path: Path = field( + default_factory=_get_mds_client_combined_cert_path + ) # path to file containing client certificate and key + + +def _certs_exist(mds_mtls_config: MdsMtlsConfig): + """Checks if the mTLS certificates exist.""" + return os.path.exists(mds_mtls_config.ca_cert_path) and os.path.exists( + mds_mtls_config.client_combined_cert_path + ) + + +class MdsMtlsMode(enum.Enum): + """MDS mTLS mode. Used to configure connection behavior when connecting to MDS. + + STRICT: Always use HTTPS/mTLS. If certificates are not found locally, an error will be returned. + NONE: Never use mTLS. Requests will use regular HTTP. + DEFAULT: Use mTLS if certificates are found locally, otherwise use regular HTTP. + """ + + STRICT = "strict" + NONE = "none" + DEFAULT = "default" + + +def _parse_mds_mode(): + """Parses the GCE_METADATA_MTLS_MODE environment variable.""" + mode_str = os.environ.get( + environment_vars.GCE_METADATA_MTLS_MODE, "default" + ).lower() + try: + return MdsMtlsMode(mode_str) + except ValueError: + raise ValueError( + "Invalid value for GCE_METADATA_MTLS_MODE. Must be one of 'strict', 'none', or 'default'." + ) + + +def should_use_mds_mtls(mds_mtls_config: MdsMtlsConfig = MdsMtlsConfig()): + """Determines if mTLS should be used for the metadata server.""" + mode = _parse_mds_mode() + if mode == MdsMtlsMode.STRICT: + if not _certs_exist(mds_mtls_config): + raise exceptions.MutualTLSChannelError( + "mTLS certificates not found in strict mode." + ) + return True + elif mode == MdsMtlsMode.NONE: + return False + else: # Default mode + return _certs_exist(mds_mtls_config) + + +class MdsMtlsAdapter(HTTPAdapter): + """An HTTP adapter that uses mTLS for the metadata server.""" + + def __init__( + self, mds_mtls_config: MdsMtlsConfig = MdsMtlsConfig(), *args, **kwargs + ): + self.ssl_context = ssl.create_default_context() + self.ssl_context.load_verify_locations(cafile=mds_mtls_config.ca_cert_path) + self.ssl_context.load_cert_chain( + certfile=mds_mtls_config.client_combined_cert_path + ) + super(MdsMtlsAdapter, self).__init__(*args, **kwargs) + + def init_poolmanager(self, *args, **kwargs): + kwargs["ssl_context"] = self.ssl_context + return super(MdsMtlsAdapter, self).init_poolmanager(*args, **kwargs) + + def proxy_manager_for(self, *args, **kwargs): + kwargs["ssl_context"] = self.ssl_context + return super(MdsMtlsAdapter, self).proxy_manager_for(*args, **kwargs) + + def send(self, request, **kwargs): + # If we are in strict mode, always use mTLS (no HTTP fallback) + if _parse_mds_mode() == MdsMtlsMode.STRICT: + return super(MdsMtlsAdapter, self).send(request, **kwargs) + + # In default mode, attempt mTLS first, then fallback to HTTP on failure + try: + response = super(MdsMtlsAdapter, self).send(request, **kwargs) + response.raise_for_status() + return response + except ( + ssl.SSLError, + requests.exceptions.SSLError, + requests.exceptions.HTTPError, + ) as e: + _LOGGER.warning( + "mTLS connection to Compute Engine Metadata server failed. " + "Falling back to standard HTTP. Reason: %s", + e, + ) + # Fallback to standard HTTP + parsed_original_url = urlparse(request.url) + http_fallback_url = urlunparse(parsed_original_url._replace(scheme="http")) + request.url = http_fallback_url + + # Use a standard HTTPAdapter for the fallback + http_adapter = HTTPAdapter() + return http_adapter.send(request, **kwargs) diff --git a/packages/google-auth/google/auth/compute_engine/credentials.py b/packages/google-auth/google/auth/compute_engine/credentials.py new file mode 100644 index 000000000000..192b811b52a8 --- /dev/null +++ b/packages/google-auth/google/auth/compute_engine/credentials.py @@ -0,0 +1,556 @@ +# Copyright 2016 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. + +"""Google Compute Engine credentials. + +This module provides authentication for an application running on Google +Compute Engine using the Compute Engine metadata server. + +""" + +import datetime + +from google.auth import _helpers +from google.auth import credentials +from google.auth import exceptions +from google.auth import iam +from google.auth import jwt +from google.auth import metrics +from google.auth.compute_engine import _metadata +from google.oauth2 import _client + +_TRUST_BOUNDARY_LOOKUP_ENDPOINT = ( + "https://iamcredentials.{}/v1/projects/-/serviceAccounts/{}/allowedLocations" +) + + +class Credentials( + credentials.Scoped, + credentials.CredentialsWithQuotaProject, + credentials.CredentialsWithUniverseDomain, + credentials.CredentialsWithTrustBoundary, +): + """Compute Engine Credentials. + + These credentials use the Google Compute Engine metadata server to obtain + OAuth 2.0 access tokens associated with the instance's service account, + and are also used for Cloud Run, Flex and App Engine (except for the Python + 2.7 runtime, which is supported only on older versions of this library). + + For more information about Compute Engine authentication, including how + to configure scopes, see the `Compute Engine authentication + documentation`_. + + .. note:: On Compute Engine the metadata server ignores requested scopes. + On Cloud Run, Flex and App Engine the server honours requested scopes. + + .. _Compute Engine authentication documentation: + https://cloud.google.com/compute/docs/authentication#using + """ + + def __init__( + self, + service_account_email="default", + quota_project_id=None, + scopes=None, + default_scopes=None, + universe_domain=None, + trust_boundary=None, + ): + """ + Args: + service_account_email (str): The service account email to use, or + 'default'. A Compute Engine instance may have multiple service + accounts. + quota_project_id (Optional[str]): The project ID used for quota and + billing. + scopes (Optional[Sequence[str]]): The list of scopes for the credentials. + default_scopes (Optional[Sequence[str]]): Default scopes passed by a + Google client library. Use 'scopes' for user-defined scopes. + universe_domain (Optional[str]): The universe domain. If not + provided or None, credential will attempt to fetch the value + from metadata server. If metadata server doesn't have universe + domain endpoint, then the default googleapis.com will be used. + trust_boundary (Mapping[str,str]): A credential trust boundary. + """ + super(Credentials, self).__init__() + self._service_account_email = service_account_email + self._quota_project_id = quota_project_id + self._scopes = scopes + self._default_scopes = default_scopes + self._universe_domain_cached = False + if universe_domain: + self._universe_domain = universe_domain + self._universe_domain_cached = True + self._trust_boundary = trust_boundary + + def _retrieve_info(self, request): + """Retrieve information about the service account. + + Updates the scopes and retrieves the full service account email. + + Args: + request (google.auth.transport.Request): The object used to make + HTTP requests. + """ + info = _metadata.get_service_account_info( + request, service_account=self._service_account_email + ) + + if not info or "email" not in info: + raise exceptions.RefreshError( + "Unexpected response from metadata server: " + "service account info is missing 'email' field." + ) + + self._service_account_email = info["email"] + + # Don't override scopes requested by the user. + if self._scopes is None: + self._scopes = info.get("scopes") + + def _metric_header_for_usage(self): + return metrics.CRED_TYPE_SA_MDS + + def _refresh_token(self, request): + """Refresh the access token and scopes. + + Args: + request (google.auth.transport.Request): The object used to make + HTTP requests. + + Raises: + google.auth.exceptions.RefreshError: If the Compute Engine metadata + service can't be reached if if the instance has not + credentials. + """ + scopes = self._scopes if self._scopes is not None else self._default_scopes + try: + self._retrieve_info(request) + # Always fetch token with default service account email. + self.token, self.expiry = _metadata.get_service_account_token( + request, service_account="default", scopes=scopes + ) + except exceptions.TransportError as caught_exc: + new_exc = exceptions.RefreshError(caught_exc) + raise new_exc from caught_exc + + def _build_trust_boundary_lookup_url(self): + """Builds and returns the URL for the trust boundary lookup API for GCE.""" + # If the service account email is 'default', we need to get the + # actual email address from the metadata server. + if self._service_account_email == "default": + from google.auth.transport import requests as google_auth_requests + + request = google_auth_requests.Request() + try: + info = _metadata.get_service_account_info(request, "default") + if not info or "email" not in info: + raise exceptions.RefreshError( + "Unexpected response from metadata server: " + "service account info is missing 'email' field." + ) + self._service_account_email = info["email"] + + except exceptions.TransportError as e: + # If fetching the service account email fails due to a transport error, + # it means we cannot build the trust boundary lookup URL. + # Wrap this in a RefreshError so it's caught by _refresh_trust_boundary. + raise exceptions.RefreshError( + "Failed to get service account email for trust boundary lookup: {}".format( + e + ) + ) from e + + return _TRUST_BOUNDARY_LOOKUP_ENDPOINT.format( + self.universe_domain, self.service_account_email + ) + + @property + def service_account_email(self): + """The service account email. + + .. note:: This is not guaranteed to be set until :meth:`refresh` has been + called. + """ + return self._service_account_email + + @property + def requires_scopes(self): + return not self._scopes + + @property + def universe_domain(self): + if self._universe_domain_cached: + return self._universe_domain + + from google.auth.transport import requests as google_auth_requests + + self._universe_domain = _metadata.get_universe_domain( + google_auth_requests.Request() + ) + self._universe_domain_cached = True + return self._universe_domain + + @_helpers.copy_docstring(credentials.Credentials) + def get_cred_info(self): + return { + "credential_source": "metadata server", + "credential_type": "VM credentials", + "principal": self.service_account_email, + } + + @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) + def with_quota_project(self, quota_project_id): + creds = self.__class__( + service_account_email=self._service_account_email, + quota_project_id=quota_project_id, + scopes=self._scopes, + default_scopes=self._default_scopes, + universe_domain=self._universe_domain, + trust_boundary=self._trust_boundary, + ) + creds._universe_domain_cached = self._universe_domain_cached + return creds + + @_helpers.copy_docstring(credentials.Scoped) + def with_scopes(self, scopes, default_scopes=None): + # Compute Engine credentials can not be scoped (the metadata service + # ignores the scopes parameter). App Engine, Cloud Run and Flex support + # requesting scopes. + creds = self.__class__( + scopes=scopes, + default_scopes=default_scopes, + service_account_email=self._service_account_email, + quota_project_id=self._quota_project_id, + universe_domain=self._universe_domain, + trust_boundary=self._trust_boundary, + ) + creds._universe_domain_cached = self._universe_domain_cached + return creds + + @_helpers.copy_docstring(credentials.CredentialsWithUniverseDomain) + def with_universe_domain(self, universe_domain): + return self.__class__( + scopes=self._scopes, + default_scopes=self._default_scopes, + service_account_email=self._service_account_email, + quota_project_id=self._quota_project_id, + trust_boundary=self._trust_boundary, + universe_domain=universe_domain, + ) + + @_helpers.copy_docstring(credentials.CredentialsWithTrustBoundary) + def with_trust_boundary(self, trust_boundary): + creds = self.__class__( + service_account_email=self._service_account_email, + quota_project_id=self._quota_project_id, + scopes=self._scopes, + default_scopes=self._default_scopes, + universe_domain=self._universe_domain, + trust_boundary=trust_boundary, + ) + creds._universe_domain_cached = self._universe_domain_cached + return creds + + +_DEFAULT_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds +_DEFAULT_TOKEN_URI = "https://www.googleapis.com/oauth2/v4/token" + + +class IDTokenCredentials( + credentials.CredentialsWithQuotaProject, + credentials.Signing, + credentials.CredentialsWithTokenUri, +): + """Open ID Connect ID Token-based service account credentials. + + These credentials relies on the default service account of a GCE instance. + + ID token can be requested from `GCE metadata server identity endpoint`_, IAM + token endpoint or other token endpoints you specify. If metadata server + identity endpoint is not used, the GCE instance must have been started with + a service account that has access to the IAM Cloud API. + + .. _GCE metadata server identity endpoint: + https://cloud.google.com/compute/docs/instances/verifying-instance-identity + """ + + def __init__( + self, + request, + target_audience, + token_uri=None, + additional_claims=None, + service_account_email=None, + signer=None, + use_metadata_identity_endpoint=False, + quota_project_id=None, + ): + """ + Args: + request (google.auth.transport.Request): The object used to make + HTTP requests. + target_audience (str): The intended audience for these credentials, + used when requesting the ID Token. The ID Token's ``aud`` claim + will be set to this string. + token_uri (str): The OAuth 2.0 Token URI. + additional_claims (Mapping[str, str]): Any additional claims for + the JWT assertion used in the authorization grant. + service_account_email (str): Optional explicit service account to + use to sign JWT tokens. + By default, this is the default GCE service account. + signer (google.auth.crypt.Signer): The signer used to sign JWTs. + In case the signer is specified, the request argument will be + ignored. + use_metadata_identity_endpoint (bool): Whether to use GCE metadata + identity endpoint. For backward compatibility the default value + is False. If set to True, ``token_uri``, ``additional_claims``, + ``service_account_email``, ``signer`` argument should not be set; + otherwise ValueError will be raised. + quota_project_id (Optional[str]): The project ID used for quota and + billing. + + Raises: + ValueError: + If ``use_metadata_identity_endpoint`` is set to True, and one of + ``token_uri``, ``additional_claims``, ``service_account_email``, + ``signer`` arguments is set. + """ + super(IDTokenCredentials, self).__init__() + + self._quota_project_id = quota_project_id + self._use_metadata_identity_endpoint = use_metadata_identity_endpoint + self._target_audience = target_audience + + if use_metadata_identity_endpoint: + if token_uri or additional_claims or service_account_email or signer: + raise ValueError( + "If use_metadata_identity_endpoint is set, token_uri, " + "additional_claims, service_account_email, signer arguments" + " must not be set" + ) + self._token_uri = None + self._additional_claims = None + self._signer = None + + if service_account_email is None: + sa_info = _metadata.get_service_account_info(request) + self._service_account_email = sa_info["email"] + else: + self._service_account_email = service_account_email + + if not use_metadata_identity_endpoint: + if signer is None: + signer = iam.Signer( + request=request, + credentials=Credentials(), + service_account_email=self._service_account_email, + ) + self._signer = signer + self._token_uri = token_uri or _DEFAULT_TOKEN_URI + + if additional_claims is not None: + self._additional_claims = additional_claims + else: + self._additional_claims = {} + + def with_target_audience(self, target_audience): + """Create a copy of these credentials with the specified target + audience. + Args: + target_audience (str): The intended audience for these credentials, + used when requesting the ID Token. + Returns: + google.auth.service_account.IDTokenCredentials: A new credentials + instance. + """ + # since the signer is already instantiated, + # the request is not needed + if self._use_metadata_identity_endpoint: + return self.__class__( + None, + target_audience=target_audience, + use_metadata_identity_endpoint=True, + quota_project_id=self._quota_project_id, + ) + else: + return self.__class__( + None, + service_account_email=self._service_account_email, + token_uri=self._token_uri, + target_audience=target_audience, + additional_claims=self._additional_claims.copy(), + signer=self.signer, + use_metadata_identity_endpoint=False, + quota_project_id=self._quota_project_id, + ) + + @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) + def with_quota_project(self, quota_project_id): + # since the signer is already instantiated, + # the request is not needed + if self._use_metadata_identity_endpoint: + return self.__class__( + None, + target_audience=self._target_audience, + use_metadata_identity_endpoint=True, + quota_project_id=quota_project_id, + ) + else: + return self.__class__( + None, + service_account_email=self._service_account_email, + token_uri=self._token_uri, + target_audience=self._target_audience, + additional_claims=self._additional_claims.copy(), + signer=self.signer, + use_metadata_identity_endpoint=False, + quota_project_id=quota_project_id, + ) + + @_helpers.copy_docstring(credentials.CredentialsWithTokenUri) + def with_token_uri(self, token_uri): + # since the signer is already instantiated, + # the request is not needed + if self._use_metadata_identity_endpoint: + raise ValueError( + "If use_metadata_identity_endpoint is set, token_uri" " must not be set" + ) + else: + return self.__class__( + None, + service_account_email=self._service_account_email, + token_uri=token_uri, + target_audience=self._target_audience, + additional_claims=self._additional_claims.copy(), + signer=self.signer, + use_metadata_identity_endpoint=False, + quota_project_id=self.quota_project_id, + ) + + def _make_authorization_grant_assertion(self): + """Create the OAuth 2.0 assertion. + This assertion is used during the OAuth 2.0 grant to acquire an + ID token. + Returns: + bytes: The authorization grant assertion. + """ + now = _helpers.utcnow() + lifetime = datetime.timedelta(seconds=_DEFAULT_TOKEN_LIFETIME_SECS) + expiry = now + lifetime + + payload = { + "iat": _helpers.datetime_to_secs(now), + "exp": _helpers.datetime_to_secs(expiry), + # The issuer must be the service account email. + "iss": self.service_account_email, + # The audience must be the auth token endpoint's URI + "aud": self._token_uri, + # The target audience specifies which service the ID token is + # intended for. + "target_audience": self._target_audience, + } + + payload.update(self._additional_claims) + + token = jwt.encode(self._signer, payload) + + return token + + def _call_metadata_identity_endpoint(self, request): + """Request ID token from metadata identity endpoint. + + Args: + request (google.auth.transport.Request): The object used to make + HTTP requests. + + Returns: + Tuple[str, datetime.datetime]: The ID token and the expiry of the ID token. + + Raises: + google.auth.exceptions.RefreshError: If the Compute Engine metadata + service can't be reached or if the instance has no credentials. + ValueError: If extracting expiry from the obtained ID token fails. + """ + try: + path = "instance/service-accounts/default/identity" + params = {"audience": self._target_audience, "format": "full"} + metrics_header = { + metrics.API_CLIENT_HEADER: metrics.token_request_id_token_mds() + } + id_token = _metadata.get( + request, path, params=params, headers=metrics_header + ) + except exceptions.TransportError as caught_exc: + new_exc = exceptions.RefreshError(caught_exc) + raise new_exc from caught_exc + + _, payload, _, _ = jwt._unverified_decode(id_token) + return id_token, datetime.datetime.utcfromtimestamp(payload["exp"]) + + def refresh(self, request): + """Refreshes the ID token. + + Args: + request (google.auth.transport.Request): The object used to make + HTTP requests. + + Raises: + google.auth.exceptions.RefreshError: If the credentials could + not be refreshed. + ValueError: If extracting expiry from the obtained ID token fails. + """ + if self._use_metadata_identity_endpoint: + self.token, self.expiry = self._call_metadata_identity_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 # type: ignore + @_helpers.copy_docstring(credentials.Signing) + def signer(self): + return self._signer + + def sign_bytes(self, message): + """Signs the given message. + + Args: + message (bytes): The message to sign. + + Returns: + bytes: The message's cryptographic signature. + + Raises: + ValueError: + Signer is not available if metadata identity endpoint is used. + """ + if self._use_metadata_identity_endpoint: + raise exceptions.InvalidOperation( + "Signer is not available if metadata identity endpoint is used" + ) + return self._signer.sign(message) + + @property + def service_account_email(self): + """The service account email.""" + return self._service_account_email + + @property + def signer_email(self): + return self._service_account_email diff --git a/packages/google-auth/google/auth/credentials.py b/packages/google-auth/google/auth/credentials.py new file mode 100644 index 000000000000..82c73c3bfd5f --- /dev/null +++ b/packages/google-auth/google/auth/credentials.py @@ -0,0 +1,665 @@ +# Copyright 2016 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. + + +"""Interfaces for credentials.""" + +import abc +from enum import Enum +import os +from typing import List + +from google.auth import _helpers, environment_vars +from google.auth import exceptions +from google.auth import metrics +from google.auth._credentials_base import _BaseCredentials +from google.auth._default import _LOGGER +from google.auth._refresh_worker import RefreshThreadManager + +DEFAULT_UNIVERSE_DOMAIN = "googleapis.com" +NO_OP_TRUST_BOUNDARY_LOCATIONS: List[str] = [] +NO_OP_TRUST_BOUNDARY_ENCODED_LOCATIONS = "0x0" + + +class Credentials(_BaseCredentials): + """Base class for all credentials. + + All credentials have a :attr:`token` that is used for authentication and + may also optionally set an :attr:`expiry` to indicate when the token will + no longer be valid. + + Most credentials will be :attr:`invalid` until :meth:`refresh` is called. + Credentials can do this automatically before the first HTTP request in + :meth:`before_request`. + + Although the token and expiration will change as the credentials are + :meth:`refreshed ` and used, credentials should be considered + immutable. Various credentials will accept configuration such as private + keys, scopes, and other options. These options are not changeable after + construction. Some classes will provide mechanisms to copy the credentials + with modifications such as :meth:`ScopedCredentials.with_scopes`. + """ + + def __init__(self): + super(Credentials, self).__init__() + + self.expiry = None + """Optional[datetime]: When the token expires and is no longer valid. + If this is None, the token is assumed to never expire.""" + self._quota_project_id = None + """Optional[str]: Project to use for quota and billing purposes.""" + self._trust_boundary = None + """Optional[dict]: Cache of a trust boundary response which has a list + of allowed regions and an encoded string representation of credentials + trust boundary.""" + self._universe_domain = DEFAULT_UNIVERSE_DOMAIN + """Optional[str]: The universe domain value, default is googleapis.com + """ + + self._use_non_blocking_refresh = False + self._refresh_worker = RefreshThreadManager() + + @property + def expired(self): + """Checks if the credentials are expired. + + Note that credentials can be invalid but not expired because + Credentials with :attr:`expiry` set to None is considered to never + expire. + + .. deprecated:: v2.24.0 + Prefer checking :attr:`token_state` instead. + """ + if not self.expiry: + return False + # Remove some threshold from expiry to err on the side of reporting + # expiration early so that we avoid the 401-refresh-retry loop. + skewed_expiry = self.expiry - _helpers.REFRESH_THRESHOLD + return _helpers.utcnow() >= skewed_expiry + + @property + def valid(self): + """Checks the validity of the credentials. + + This is True if the credentials have a :attr:`token` and the token + is not :attr:`expired`. + + .. deprecated:: v2.24.0 + Prefer checking :attr:`token_state` instead. + """ + return self.token is not None and not self.expired + + @property + def token_state(self): + """ + See `:obj:`TokenState` + """ + if self.token is None: + return TokenState.INVALID + + # Credentials that can't expire are always treated as fresh. + if self.expiry is None: + return TokenState.FRESH + + expired = _helpers.utcnow() >= self.expiry + if expired: + return TokenState.INVALID + + is_stale = _helpers.utcnow() >= (self.expiry - _helpers.REFRESH_THRESHOLD) + if is_stale: + return TokenState.STALE + + return TokenState.FRESH + + @property + def quota_project_id(self): + """Project to use for quota and billing purposes.""" + return self._quota_project_id + + @property + def universe_domain(self): + """The universe domain value.""" + return self._universe_domain + + def get_cred_info(self): + """The credential information JSON. + + The credential information will be added to auth related error messages + by client library. + + Returns: + Mapping[str, str]: The credential information JSON. + """ + return None + + @abc.abstractmethod + def refresh(self, request): + """Refreshes the access token. + + Args: + request (google.auth.transport.Request): The object used to make + HTTP requests. + + Raises: + google.auth.exceptions.RefreshError: If the credentials could + not be refreshed. + """ + # pylint: disable=missing-raises-doc + # (pylint doesn't recognize that this is abstract) + raise NotImplementedError("Refresh must be implemented") + + def _metric_header_for_usage(self): + """The x-goog-api-client header for token usage metric. + + This header will be added to the API service requests in before_request + method. For example, "cred-type/sa-jwt" means service account self + signed jwt access token is used in the API service request + authorization header. Children credentials classes need to override + this method to provide the header value, if the token usage metric is + needed. + + Returns: + str: The x-goog-api-client header value. + """ + return None + + def apply(self, headers, token=None): + """Apply the token to the authentication header. + + Args: + headers (Mapping): The HTTP request headers. + token (Optional[str]): If specified, overrides the current access + token. + """ + self._apply(headers, token) + if self.quota_project_id: + headers["x-goog-user-project"] = self.quota_project_id + + def _blocking_refresh(self, request): + if not self.valid: + self.refresh(request) + + def _non_blocking_refresh(self, request): + use_blocking_refresh_fallback = False + + if self.token_state == TokenState.STALE: + use_blocking_refresh_fallback = not self._refresh_worker.start_refresh( + self, request + ) + + if self.token_state == TokenState.INVALID or use_blocking_refresh_fallback: + self.refresh(request) + # If the blocking refresh succeeds then we can clear the error info + # on the background refresh worker, and perform refreshes in a + # background thread. + self._refresh_worker.clear_error() + + def before_request(self, request, method, url, headers): + """Performs credential-specific before request logic. + + Refreshes the credentials if necessary, then calls :meth:`apply` to + apply the token to the authentication header. + + Args: + request (google.auth.transport.Request): The object used to make + HTTP requests. + method (str): The request's HTTP method or the RPC method being + invoked. + url (str): The request's URI or the RPC service's URI. + headers (Mapping): The request's headers. + """ + # pylint: disable=unused-argument + # (Subclasses may use these arguments to ascertain information about + # the http request.) + if self._use_non_blocking_refresh: + self._non_blocking_refresh(request) + else: + self._blocking_refresh(request) + + metrics.add_metric_header(headers, self._metric_header_for_usage()) + self.apply(headers) + + def with_non_blocking_refresh(self): + self._use_non_blocking_refresh = True + + +class CredentialsWithQuotaProject(Credentials): + """Abstract base for credentials supporting ``with_quota_project`` factory""" + + def with_quota_project(self, quota_project_id): + """Returns a copy of these credentials with a modified quota project. + + Args: + quota_project_id (str): The project to use for quota and + billing purposes + + Returns: + google.auth.credentials.Credentials: A new credentials instance. + """ + raise NotImplementedError("This credential does not support quota project.") + + def with_quota_project_from_environment(self): + quota_from_env = os.environ.get(environment_vars.GOOGLE_CLOUD_QUOTA_PROJECT) + if quota_from_env: + return self.with_quota_project(quota_from_env) + return self + + +class CredentialsWithTokenUri(Credentials): + """Abstract base for credentials supporting ``with_token_uri`` factory""" + + def with_token_uri(self, token_uri): + """Returns a copy of these credentials with a modified token uri. + + Args: + token_uri (str): The uri to use for fetching/exchanging tokens + + Returns: + google.auth.credentials.Credentials: A new credentials instance. + """ + raise NotImplementedError("This credential does not use token uri.") + + +class CredentialsWithUniverseDomain(Credentials): + """Abstract base for credentials supporting ``with_universe_domain`` factory""" + + def with_universe_domain(self, universe_domain): + """Returns a copy of these credentials with a modified universe domain. + + Args: + universe_domain (str): The universe domain to use + + Returns: + google.auth.credentials.Credentials: A new credentials instance. + """ + raise NotImplementedError( + "This credential does not support with_universe_domain." + ) + + +class CredentialsWithTrustBoundary(Credentials): + """Abstract base for credentials supporting ``with_trust_boundary`` factory""" + + @abc.abstractmethod + def _refresh_token(self, request): + """Refreshes the access token. + + Args: + request (google.auth.transport.Request): The object used to make + HTTP requests. + + Raises: + google.auth.exceptions.RefreshError: If the credentials could + not be refreshed. + """ + raise NotImplementedError("_refresh_token must be implemented") + + def with_trust_boundary(self, trust_boundary): + """Returns a copy of these credentials with a modified trust boundary. + + Args: + trust_boundary Mapping[str, str]: The trust boundary to use for the + credential. This should be a map with a "locations" key that maps to + a list of GCP regions, and a "encodedLocations" key that maps to a + hex string. + + Returns: + google.auth.credentials.Credentials: A new credentials instance. + """ + raise NotImplementedError("This credential does not support trust boundaries.") + + def _is_trust_boundary_lookup_required(self): + """Checks if a trust boundary lookup is required. + + A lookup is required if the feature is enabled via an environment + variable, the universe domain is supported, and a no-op boundary + is not already cached. + + Returns: + bool: True if a trust boundary lookup is required, False otherwise. + """ + # 1. Check if the feature is enabled via environment variable. + if not _helpers.get_bool_from_env( + environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED, default=False + ): + return False + + # 2. Skip trust boundary flow for non-default universe domains. + if self.universe_domain != DEFAULT_UNIVERSE_DOMAIN: + return False + + # 3. Do not trigger refresh if credential has a cached no-op trust boundary. + return not self._has_no_op_trust_boundary() + + def _get_trust_boundary_header(self): + if self._trust_boundary is not None: + if self._has_no_op_trust_boundary(): + # STS expects an empty string if the trust boundary value is no-op. + return {"x-allowed-locations": ""} + else: + return {"x-allowed-locations": self._trust_boundary["encodedLocations"]} + return {} + + def apply(self, headers, token=None): + """Apply the token to the authentication header.""" + super().apply(headers, token) + headers.update(self._get_trust_boundary_header()) + + def refresh(self, request): + """Refreshes the access token and the trust boundary. + + This method calls the subclass's token refresh logic and then + refreshes the trust boundary if applicable. + """ + self._refresh_token(request) + self._refresh_trust_boundary(request) + + def _refresh_trust_boundary(self, request): + """Triggers a refresh of the trust boundary and updates the cache if necessary. + + Args: + request (google.auth.transport.Request): The object used to make + HTTP requests. + + Raises: + google.auth.exceptions.RefreshError: If the trust boundary could + not be refreshed and no cached value is available. + """ + if not self._is_trust_boundary_lookup_required(): + return + try: + self._trust_boundary = self._lookup_trust_boundary(request) + except exceptions.RefreshError as error: + # If the call to the lookup API failed, check if there is a trust boundary + # already cached. If there is, do nothing. If not, then throw the error. + if self._trust_boundary is None: + raise error + if _helpers.is_logging_enabled(_LOGGER): + _LOGGER.debug( + "Using cached trust boundary due to refresh error: %s", error + ) + return + + def _lookup_trust_boundary(self, request): + """Calls the trust boundary lookup API to refresh the trust boundary cache. + + Args: + request (google.auth.transport.Request): The object used to make + HTTP requests. + + Returns: + trust_boundary (dict): The trust boundary object returned by the lookup API. + + Raises: + google.auth.exceptions.RefreshError: If the trust boundary could not be + retrieved. + """ + from google.oauth2 import _client + + url = self._build_trust_boundary_lookup_url() + if not url: + raise exceptions.InvalidValue("Failed to build trust boundary lookup URL.") + + headers = {} + self._apply(headers) + headers.update(self._get_trust_boundary_header()) + return _client._lookup_trust_boundary(request, url, headers=headers) + + @abc.abstractmethod + def _build_trust_boundary_lookup_url(self): + """ + Builds and returns the URL for the trust boundary lookup API. + + This method should be implemented by subclasses to provide the + specific URL based on the credential type and its properties. + + Returns: + str: The URL for the trust boundary lookup endpoint, or None + if lookup should be skipped (e.g., for non-applicable universe domains). + """ + raise NotImplementedError( + "_build_trust_boundary_lookup_url must be implemented" + ) + + def _has_no_op_trust_boundary(self): + # A no-op trust boundary is indicated by encodedLocations being "0x0". + # The "locations" list may or may not be present as an empty list. + if self._trust_boundary is None: + return False + return ( + self._trust_boundary.get("encodedLocations") + == NO_OP_TRUST_BOUNDARY_ENCODED_LOCATIONS + ) + + +class AnonymousCredentials(Credentials): + """Credentials that do not provide any authentication information. + + These are useful in the case of services that support anonymous access or + local service emulators that do not use credentials. + """ + + @property + def expired(self): + """Returns `False`, anonymous credentials never expire.""" + return False + + @property + def valid(self): + """Returns `True`, anonymous credentials are always valid.""" + return True + + def refresh(self, request): + """Raises :class:``InvalidOperation``, anonymous credentials cannot be + refreshed.""" + raise exceptions.InvalidOperation("Anonymous credentials cannot be refreshed.") + + def apply(self, headers, token=None): + """Anonymous credentials do nothing to the request. + + The optional ``token`` argument is not supported. + + Raises: + google.auth.exceptions.InvalidValue: If a token was specified. + """ + if token is not None: + raise exceptions.InvalidValue("Anonymous credentials don't support tokens.") + + def before_request(self, request, method, url, headers): + """Anonymous credentials do nothing to the request.""" + + +class ReadOnlyScoped(metaclass=abc.ABCMeta): + """Interface for credentials whose scopes can be queried. + + OAuth 2.0-based credentials allow limiting access using scopes as described + in `RFC6749 Section 3.3`_. + If a credential class implements this interface then the credentials either + use scopes in their implementation. + + Some credentials require scopes in order to obtain a token. You can check + if scoping is necessary with :attr:`requires_scopes`:: + + if credentials.requires_scopes: + # Scoping is required. + credentials = credentials.with_scopes(scopes=['one', 'two']) + + Credentials that require scopes must either be constructed with scopes:: + + credentials = SomeScopedCredentials(scopes=['one', 'two']) + + Or must copy an existing instance using :meth:`with_scopes`:: + + scoped_credentials = credentials.with_scopes(scopes=['one', 'two']) + + Some credentials have scopes but do not allow or require scopes to be set, + these credentials can be used as-is. + + .. _RFC6749 Section 3.3: https://tools.ietf.org/html/rfc6749#section-3.3 + """ + + def __init__(self): + super(ReadOnlyScoped, self).__init__() + self._scopes = None + self._default_scopes = None + + @property + def scopes(self): + """Sequence[str]: the credentials' current set of scopes.""" + return self._scopes + + @property + def default_scopes(self): + """Sequence[str]: the credentials' current set of default scopes.""" + return self._default_scopes + + @abc.abstractproperty + def requires_scopes(self): + """True if these credentials require scopes to obtain an access token.""" + return False + + def has_scopes(self, scopes): + """Checks if the credentials have the given scopes. + + .. warning: This method is not guaranteed to be accurate if the + credentials are :attr:`~Credentials.invalid`. + + Args: + scopes (Sequence[str]): The list of scopes to check. + + Returns: + bool: True if the credentials have the given scopes. + """ + credential_scopes = ( + self._scopes if self._scopes is not None else self._default_scopes + ) + return set(scopes).issubset(set(credential_scopes or [])) + + +class Scoped(ReadOnlyScoped): + """Interface for credentials whose scopes can be replaced while copying. + + OAuth 2.0-based credentials allow limiting access using scopes as described + in `RFC6749 Section 3.3`_. + If a credential class implements this interface then the credentials either + use scopes in their implementation. + + Some credentials require scopes in order to obtain a token. You can check + if scoping is necessary with :attr:`requires_scopes`:: + + if credentials.requires_scopes: + # Scoping is required. + credentials = credentials.create_scoped(['one', 'two']) + + Credentials that require scopes must either be constructed with scopes:: + + credentials = SomeScopedCredentials(scopes=['one', 'two']) + + Or must copy an existing instance using :meth:`with_scopes`:: + + scoped_credentials = credentials.with_scopes(scopes=['one', 'two']) + + Some credentials have scopes but do not allow or require scopes to be set, + these credentials can be used as-is. + + .. _RFC6749 Section 3.3: https://tools.ietf.org/html/rfc6749#section-3.3 + """ + + @abc.abstractmethod + def with_scopes(self, scopes, default_scopes=None): + """Create a copy of these credentials with the specified scopes. + + Args: + scopes (Sequence[str]): The list of scopes to attach to the + current credentials. + + Raises: + NotImplementedError: If the credentials' scopes can not be changed. + This can be avoided by checking :attr:`requires_scopes` before + calling this method. + """ + raise NotImplementedError("This class does not require scoping.") + + +def with_scopes_if_required(credentials, scopes, default_scopes=None): + """Creates a copy of the credentials with scopes if scoping is required. + + This helper function is useful when you do not know (or care to know) the + specific type of credentials you are using (such as when you use + :func:`google.auth.default`). This function will call + :meth:`Scoped.with_scopes` if the credentials are scoped credentials and if + the credentials require scoping. Otherwise, it will return the credentials + as-is. + + Args: + credentials (google.auth.credentials.Credentials): The credentials to + scope if necessary. + scopes (Sequence[str]): The list of scopes to use. + default_scopes (Sequence[str]): Default scopes passed by a + Google client library. Use 'scopes' for user-defined scopes. + + Returns: + google.auth.credentials.Credentials: Either a new set of scoped + credentials, or the passed in credentials instance if no scoping + was required. + """ + if isinstance(credentials, Scoped) and credentials.requires_scopes: + return credentials.with_scopes(scopes, default_scopes=default_scopes) + else: + return credentials + + +class Signing(metaclass=abc.ABCMeta): + """Interface for credentials that can cryptographically sign messages.""" + + @abc.abstractmethod + def sign_bytes(self, message): + """Signs the given message. + + Args: + message (bytes): The message to sign. + + Returns: + bytes: The message's cryptographic signature. + """ + # pylint: disable=missing-raises-doc,redundant-returns-doc + # (pylint doesn't recognize that this is abstract) + raise NotImplementedError("Sign bytes must be implemented.") + + @abc.abstractproperty + def signer_email(self): + """Optional[str]: An email address that identifies the signer.""" + # pylint: disable=missing-raises-doc + # (pylint doesn't recognize that this is abstract) + raise NotImplementedError("Signer email must be implemented.") + + @abc.abstractproperty + def signer(self): + """google.auth.crypt.Signer: The signer used to sign bytes.""" + # pylint: disable=missing-raises-doc + # (pylint doesn't recognize that this is abstract) + raise NotImplementedError("Signer must be implemented.") + + +class TokenState(Enum): + """ + Tracks the state of a token. + FRESH: The token is valid. It is not expired or close to expired, or the token has no expiry. + STALE: The token is close to expired, and should be refreshed. The token can be used normally. + INVALID: The token is expired or invalid. The token cannot be used for a normal operation. + """ + + FRESH = 1 + STALE = 2 + INVALID = 3 diff --git a/packages/google-auth/google/auth/crypt/__init__.py b/packages/google-auth/google/auth/crypt/__init__.py new file mode 100644 index 000000000000..59519b475e5b --- /dev/null +++ b/packages/google-auth/google/auth/crypt/__init__.py @@ -0,0 +1,105 @@ +# Copyright 2016 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. + +"""Cryptography helpers for verifying and signing messages. + +The simplest way to verify signatures is using :func:`verify_signature`:: + + cert = open('certs.pem').read() + valid = crypt.verify_signature(message, signature, cert) + +If you're going to verify many messages with the same certificate, you can use +:class:`RSAVerifier`:: + + cert = open('certs.pem').read() + verifier = crypt.RSAVerifier.from_string(cert) + valid = verifier.verify(message, signature) + +To sign messages use :class:`RSASigner` with a private key:: + + private_key = open('private_key.pem').read() + signer = crypt.RSASigner.from_string(private_key) + signature = signer.sign(message) + +The code above also works for :class:`ES256Signer` and :class:`ES256Verifier`. +Note that these two classes are only available if your `cryptography` dependency +version is at least 1.4.0. +""" + +from google.auth.crypt import base +from google.auth.crypt import rsa + +# google.auth.crypt.es depends on the crytpography module which may not be +# successfully imported depending on the system. +try: + from google.auth.crypt import es + from google.auth.crypt import es256 +except ImportError: # pragma: NO COVER + es = None # type: ignore + es256 = None # type: ignore + +if es is not None and es256 is not None: # pragma: NO COVER + __all__ = [ + "EsSigner", + "EsVerifier", + "ES256Signer", + "ES256Verifier", + "RSASigner", + "RSAVerifier", + "Signer", + "Verifier", + ] + + EsSigner = es.EsSigner + EsVerifier = es.EsVerifier + ES256Signer = es256.ES256Signer + ES256Verifier = es256.ES256Verifier +else: # pragma: NO COVER + __all__ = ["RSASigner", "RSAVerifier", "Signer", "Verifier"] + + +# Aliases to maintain the v1.0.0 interface, as the crypt module was split +# into submodules. +Signer = base.Signer +Verifier = base.Verifier +RSASigner = rsa.RSASigner +RSAVerifier = rsa.RSAVerifier + + +def verify_signature(message, signature, certs, verifier_cls=rsa.RSAVerifier): + """Verify an RSA or ECDSA cryptographic signature. + + Checks that the provided ``signature`` was generated from ``bytes`` using + the private key associated with the ``cert``. + + Args: + message (Union[str, bytes]): The plaintext message. + signature (Union[str, bytes]): The cryptographic signature to check. + certs (Union[Sequence, str, bytes]): The certificate or certificates + to use to check the signature. + verifier_cls (Optional[~google.auth.crypt.base.Signer]): Which verifier + class to use for verification. This can be used to select different + algorithms, such as RSA or ECDSA. Default value is :class:`RSAVerifier`. + + Returns: + bool: True if the signature is valid, otherwise False. + """ + if isinstance(certs, (str, bytes)): + certs = [certs] + + for cert in certs: + verifier = verifier_cls.from_string(cert) + if verifier.verify(message, signature): + return True + return False diff --git a/packages/google-auth/google/auth/crypt/_cryptography_rsa.py b/packages/google-auth/google/auth/crypt/_cryptography_rsa.py new file mode 100644 index 000000000000..1a3e9ff52c66 --- /dev/null +++ b/packages/google-auth/google/auth/crypt/_cryptography_rsa.py @@ -0,0 +1,151 @@ +# Copyright 2017 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. + +"""RSA verifier and signer that use the ``cryptography`` library. + +This is a much faster implementation than the default (in +``google.auth.crypt._python_rsa``), which depends on the pure-Python +``rsa`` library. +""" + +import cryptography.exceptions +from cryptography.hazmat import backends +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import padding +import cryptography.x509 + +from google.auth import _helpers +from google.auth.crypt import base + +_CERTIFICATE_MARKER = b"-----BEGIN CERTIFICATE-----" +_BACKEND = backends.default_backend() +_PADDING = padding.PKCS1v15() +_SHA256 = hashes.SHA256() + + +class RSAVerifier(base.Verifier): + """Verifies RSA cryptographic signatures using public keys. + + Args: + public_key ( + cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey): + The public key used to verify signatures. + """ + + def __init__(self, public_key): + self._pubkey = public_key + + @_helpers.copy_docstring(base.Verifier) + def verify(self, message, signature): + message = _helpers.to_bytes(message) + try: + self._pubkey.verify(signature, message, _PADDING, _SHA256) + return True + except (ValueError, cryptography.exceptions.InvalidSignature): + return False + + @classmethod + def from_string(cls, public_key): + """Construct an Verifier instance from a public key or public + certificate string. + + Args: + public_key (Union[str, bytes]): The public key in PEM format or the + x509 public key certificate. + + Returns: + Verifier: The constructed verifier. + + Raises: + ValueError: If the public key can't be parsed. + """ + public_key_data = _helpers.to_bytes(public_key) + + if _CERTIFICATE_MARKER in public_key_data: + cert = cryptography.x509.load_pem_x509_certificate( + public_key_data, _BACKEND + ) + pubkey = cert.public_key() + + else: + pubkey = serialization.load_pem_public_key(public_key_data, _BACKEND) + + return cls(pubkey) + + +class RSASigner(base.Signer, base.FromServiceAccountMixin): + """Signs messages with an RSA private key. + + Args: + private_key ( + cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey): + The private key to sign with. + key_id (str): Optional key ID used to identify this private key. This + can be useful to associate the private key with its associated + public key or certificate. + """ + + def __init__(self, private_key, key_id=None): + self._key = private_key + self._key_id = key_id + + @property # type: ignore + @_helpers.copy_docstring(base.Signer) + def key_id(self): + return self._key_id + + @_helpers.copy_docstring(base.Signer) + def sign(self, message): + message = _helpers.to_bytes(message) + return self._key.sign(message, _PADDING, _SHA256) + + @classmethod + def from_string(cls, key, key_id=None): + """Construct a RSASigner from a private key in PEM format. + + Args: + key (Union[bytes, str]): Private key in PEM format. + key_id (str): An optional key id used to identify the private key. + + Returns: + google.auth.crypt._cryptography_rsa.RSASigner: The + constructed signer. + + Raises: + ValueError: If ``key`` is not ``bytes`` or ``str`` (unicode). + UnicodeDecodeError: If ``key`` is ``bytes`` but cannot be decoded + into a UTF-8 ``str``. + ValueError: If ``cryptography`` "Could not deserialize key data." + """ + key = _helpers.to_bytes(key) + private_key = serialization.load_pem_private_key( + key, password=None, backend=_BACKEND + ) + return cls(private_key, key_id=key_id) + + def __getstate__(self): + """Pickle helper that serializes the _key attribute.""" + state = self.__dict__.copy() + state["_key"] = self._key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + return state + + def __setstate__(self, state): + """Pickle helper that deserializes the _key attribute.""" + state["_key"] = serialization.load_pem_private_key(state["_key"], None) + self.__dict__.update(state) diff --git a/packages/google-auth/google/auth/crypt/_helpers.py b/packages/google-auth/google/auth/crypt/_helpers.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/google-auth/google/auth/crypt/_python_rsa.py b/packages/google-auth/google/auth/crypt/_python_rsa.py new file mode 100644 index 000000000000..e553c25ed564 --- /dev/null +++ b/packages/google-auth/google/auth/crypt/_python_rsa.py @@ -0,0 +1,175 @@ +# Copyright 2016 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. + +"""Pure-Python RSA cryptography implementation. + +Uses the ``rsa``, ``pyasn1`` and ``pyasn1_modules`` packages +to parse PEM files storing PKCS#1 or PKCS#8 keys as well as +certificates. There is no support for p12 files. +""" + +from __future__ import absolute_import + +import io + +from pyasn1.codec.der import decoder # type: ignore +from pyasn1_modules import pem # type: ignore +from pyasn1_modules.rfc2459 import Certificate # type: ignore +from pyasn1_modules.rfc5208 import PrivateKeyInfo # type: ignore +import rsa # type: ignore + +from google.auth import _helpers +from google.auth import exceptions +from google.auth.crypt import base + +_POW2 = (128, 64, 32, 16, 8, 4, 2, 1) +_CERTIFICATE_MARKER = b"-----BEGIN CERTIFICATE-----" +_PKCS1_MARKER = ("-----BEGIN RSA PRIVATE KEY-----", "-----END RSA PRIVATE KEY-----") +_PKCS8_MARKER = ("-----BEGIN PRIVATE KEY-----", "-----END PRIVATE KEY-----") +_PKCS8_SPEC = PrivateKeyInfo() + + +def _bit_list_to_bytes(bit_list): + """Converts an iterable of 1s and 0s to bytes. + + Combines the list 8 at a time, treating each group of 8 bits + as a single byte. + + Args: + bit_list (Sequence): Sequence of 1s and 0s. + + Returns: + bytes: The decoded bytes. + """ + num_bits = len(bit_list) + byte_vals = bytearray() + for start in range(0, num_bits, 8): + curr_bits = bit_list[start : start + 8] + char_val = sum(val * digit for val, digit in zip(_POW2, curr_bits)) + byte_vals.append(char_val) + return bytes(byte_vals) + + +class RSAVerifier(base.Verifier): + """Verifies RSA cryptographic signatures using public keys. + + Args: + public_key (rsa.key.PublicKey): The public key used to verify + signatures. + """ + + def __init__(self, public_key): + self._pubkey = public_key + + @_helpers.copy_docstring(base.Verifier) + def verify(self, message, signature): + message = _helpers.to_bytes(message) + try: + return rsa.pkcs1.verify(message, signature, self._pubkey) + except (ValueError, rsa.pkcs1.VerificationError): + return False + + @classmethod + def from_string(cls, public_key): + """Construct an Verifier instance from a public key or public + certificate string. + + Args: + public_key (Union[str, bytes]): The public key in PEM format or the + x509 public key certificate. + + Returns: + google.auth.crypt._python_rsa.RSAVerifier: The constructed verifier. + + Raises: + ValueError: If the public_key can't be parsed. + """ + public_key = _helpers.to_bytes(public_key) + is_x509_cert = _CERTIFICATE_MARKER in public_key + + # If this is a certificate, extract the public key info. + if is_x509_cert: + der = rsa.pem.load_pem(public_key, "CERTIFICATE") + asn1_cert, remaining = decoder.decode(der, asn1Spec=Certificate()) + if remaining != b"": + raise exceptions.InvalidValue("Unused bytes", remaining) + + cert_info = asn1_cert["tbsCertificate"]["subjectPublicKeyInfo"] + key_bytes = _bit_list_to_bytes(cert_info["subjectPublicKey"]) + pubkey = rsa.PublicKey.load_pkcs1(key_bytes, "DER") + else: + pubkey = rsa.PublicKey.load_pkcs1(public_key, "PEM") + return cls(pubkey) + + +class RSASigner(base.Signer, base.FromServiceAccountMixin): + """Signs messages with an RSA private key. + + Args: + private_key (rsa.key.PrivateKey): The private key to sign with. + key_id (str): Optional key ID used to identify this private key. This + can be useful to associate the private key with its associated + public key or certificate. + """ + + def __init__(self, private_key, key_id=None): + self._key = private_key + self._key_id = key_id + + @property # type: ignore + @_helpers.copy_docstring(base.Signer) + def key_id(self): + return self._key_id + + @_helpers.copy_docstring(base.Signer) + def sign(self, message): + message = _helpers.to_bytes(message) + return rsa.pkcs1.sign(message, self._key, "SHA-256") + + @classmethod + def from_string(cls, key, key_id=None): + """Construct an Signer instance from a private key in PEM format. + + Args: + key (str): Private key in PEM format. + key_id (str): An optional key id used to identify the private key. + + Returns: + google.auth.crypt.Signer: The constructed signer. + + Raises: + ValueError: If the key cannot be parsed as PKCS#1 or PKCS#8 in + PEM format. + """ + key = _helpers.from_bytes(key) # PEM expects str in Python 3 + marker_id, key_bytes = pem.readPemBlocksFromFile( + io.StringIO(key), _PKCS1_MARKER, _PKCS8_MARKER + ) + + # Key is in pkcs1 format. + if marker_id == 0: + private_key = rsa.key.PrivateKey.load_pkcs1(key_bytes, format="DER") + # Key is in pkcs8. + elif marker_id == 1: + key_info, remaining = decoder.decode(key_bytes, asn1Spec=_PKCS8_SPEC) + if remaining != b"": + raise exceptions.InvalidValue("Unused bytes", remaining) + private_key_info = key_info.getComponentByName("privateKey") + private_key = rsa.key.PrivateKey.load_pkcs1( + private_key_info.asOctets(), format="DER" + ) + else: + raise exceptions.MalformedError("No key could be detected.") + + return cls(private_key, key_id=key_id) diff --git a/packages/google-auth/google/auth/crypt/base.py b/packages/google-auth/google/auth/crypt/base.py new file mode 100644 index 000000000000..ad871c311566 --- /dev/null +++ b/packages/google-auth/google/auth/crypt/base.py @@ -0,0 +1,127 @@ +# Copyright 2016 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. + +"""Base classes for cryptographic signers and verifiers.""" + +import abc +import io +import json + +from google.auth import exceptions + +_JSON_FILE_PRIVATE_KEY = "private_key" +_JSON_FILE_PRIVATE_KEY_ID = "private_key_id" + + +class Verifier(metaclass=abc.ABCMeta): + """Abstract base class for crytographic signature verifiers.""" + + @abc.abstractmethod + def verify(self, message, signature): + """Verifies a message against a cryptographic signature. + + Args: + message (Union[str, bytes]): The message to verify. + signature (Union[str, bytes]): The cryptography signature to check. + + Returns: + bool: True if message was signed by the private key associated + with the public key that this object was constructed with. + """ + # pylint: disable=missing-raises-doc,redundant-returns-doc + # (pylint doesn't recognize that this is abstract) + raise NotImplementedError("Verify must be implemented") + + +class Signer(metaclass=abc.ABCMeta): + """Abstract base class for cryptographic signers.""" + + @abc.abstractproperty + def key_id(self): + """Optional[str]: The key ID used to identify this private key.""" + raise NotImplementedError("Key id must be implemented") + + @abc.abstractmethod + def sign(self, message): + """Signs a message. + + Args: + message (Union[str, bytes]): The message to be signed. + + Returns: + bytes: The signature of the message. + """ + # pylint: disable=missing-raises-doc,redundant-returns-doc + # (pylint doesn't recognize that this is abstract) + raise NotImplementedError("Sign must be implemented") + + +class FromServiceAccountMixin(metaclass=abc.ABCMeta): + """Mix-in to enable factory constructors for a Signer.""" + + @abc.abstractmethod + def from_string(cls, key, key_id=None): + """Construct an Signer instance from a private key string. + + Args: + key (str): Private key as a string. + key_id (str): An optional key id used to identify the private key. + + Returns: + google.auth.crypt.Signer: The constructed signer. + + Raises: + ValueError: If the key cannot be parsed. + """ + raise NotImplementedError("from_string must be implemented") + + @classmethod + def from_service_account_info(cls, info): + """Creates a Signer instance instance from a dictionary containing + service account info in Google format. + + Args: + info (Mapping[str, str]): The service account info in Google + format. + + Returns: + google.auth.crypt.Signer: The constructed signer. + + Raises: + ValueError: If the info is not in the expected format. + """ + if _JSON_FILE_PRIVATE_KEY not in info: + raise exceptions.MalformedError( + "The private_key field was not found in the service account " "info." + ) + + return cls.from_string( + info[_JSON_FILE_PRIVATE_KEY], info.get(_JSON_FILE_PRIVATE_KEY_ID) + ) + + @classmethod + def from_service_account_file(cls, filename): + """Creates a Signer instance from a service account .json file + in Google format. + + Args: + filename (str): The path to the service account .json file. + + Returns: + google.auth.crypt.Signer: The constructed signer. + """ + with io.open(filename, "r", encoding="utf-8") as json_file: + data = json.load(json_file) + + return cls.from_service_account_info(data) diff --git a/packages/google-auth/google/auth/crypt/es.py b/packages/google-auth/google/auth/crypt/es.py new file mode 100644 index 000000000000..f9466af3c44f --- /dev/null +++ b/packages/google-auth/google/auth/crypt/es.py @@ -0,0 +1,221 @@ +# Copyright 2017 Google Inc. +# +# 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. + +"""ECDSA verifier and signer that use the ``cryptography`` library. +""" + +from dataclasses import dataclass +from typing import Any, Dict, Optional, Union + +import cryptography.exceptions +from cryptography.hazmat import backends +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature +from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature +import cryptography.x509 + +from google.auth import _helpers +from google.auth.crypt import base + + +_CERTIFICATE_MARKER = b"-----BEGIN CERTIFICATE-----" +_BACKEND = backends.default_backend() +_PADDING = padding.PKCS1v15() + + +@dataclass +class _ESAttributes: + """A class that models ECDSA attributes. + + Attributes: + rs_size (int): Size for ASN.1 r and s size. + sha_algo (hashes.HashAlgorithm): Hash algorithm. + algorithm (str): Algorithm name. + """ + + rs_size: int + sha_algo: hashes.HashAlgorithm + algorithm: str + + @classmethod + def from_key( + cls, key: Union[ec.EllipticCurvePublicKey, ec.EllipticCurvePrivateKey] + ): + return cls.from_curve(key.curve) + + @classmethod + def from_curve(cls, curve: ec.EllipticCurve): + # ECDSA raw signature has (r||s) format where r,s are two + # integers of size 32 bytes for P-256 curve and 48 bytes + # for P-384 curve. For P-256 curve, we use SHA256 hash algo, + # and for P-384 curve we use SHA384 algo. + if isinstance(curve, ec.SECP384R1): + return cls(48, hashes.SHA384(), "ES384") + else: + # default to ES256 + return cls(32, hashes.SHA256(), "ES256") + + +class EsVerifier(base.Verifier): + """Verifies ECDSA cryptographic signatures using public keys. + + Args: + public_key ( + cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey): + The public key used to verify signatures. + """ + + def __init__(self, public_key: ec.EllipticCurvePublicKey) -> None: + self._pubkey = public_key + self._attributes = _ESAttributes.from_key(public_key) + + @_helpers.copy_docstring(base.Verifier) + def verify(self, message: bytes, signature: bytes) -> bool: + # First convert (r||s) raw signature to ASN1 encoded signature. + sig_bytes = _helpers.to_bytes(signature) + if len(sig_bytes) != self._attributes.rs_size * 2: + return False + r = int.from_bytes(sig_bytes[: self._attributes.rs_size], byteorder="big") + s = int.from_bytes(sig_bytes[self._attributes.rs_size :], byteorder="big") + asn1_sig = encode_dss_signature(r, s) + + message = _helpers.to_bytes(message) + try: + self._pubkey.verify(asn1_sig, message, ec.ECDSA(self._attributes.sha_algo)) + return True + except (ValueError, cryptography.exceptions.InvalidSignature): + return False + + @classmethod + def from_string(cls, public_key: Union[str, bytes]) -> "EsVerifier": + """Construct an Verifier instance from a public key or public + certificate string. + + Args: + public_key (Union[str, bytes]): The public key in PEM format or the + x509 public key certificate. + + Returns: + Verifier: The constructed verifier. + + Raises: + ValueError: If the public key can't be parsed. + """ + public_key_data = _helpers.to_bytes(public_key) + + if _CERTIFICATE_MARKER in public_key_data: + cert = cryptography.x509.load_pem_x509_certificate( + public_key_data, _BACKEND + ) + pubkey = cert.public_key() # type: Any + + else: + pubkey = serialization.load_pem_public_key(public_key_data, _BACKEND) + + if not isinstance(pubkey, ec.EllipticCurvePublicKey): + raise TypeError("Expected public key of type EllipticCurvePublicKey") + + return cls(pubkey) + + +class EsSigner(base.Signer, base.FromServiceAccountMixin): + """Signs messages with an ECDSA private key. + + Args: + private_key ( + cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey): + The private key to sign with. + key_id (str): Optional key ID used to identify this private key. This + can be useful to associate the private key with its associated + public key or certificate. + """ + + def __init__( + self, private_key: ec.EllipticCurvePrivateKey, key_id: Optional[str] = None + ) -> None: + self._key = private_key + self._key_id = key_id + self._attributes = _ESAttributes.from_key(private_key) + + @property + def algorithm(self) -> str: + """Name of the algorithm used to sign messages. + Returns: + str: The algorithm name. + """ + return self._attributes.algorithm + + @property # type: ignore + @_helpers.copy_docstring(base.Signer) + def key_id(self) -> Optional[str]: + return self._key_id + + @_helpers.copy_docstring(base.Signer) + def sign(self, message: bytes) -> bytes: + message = _helpers.to_bytes(message) + asn1_signature = self._key.sign(message, ec.ECDSA(self._attributes.sha_algo)) + + # Convert ASN1 encoded signature to (r||s) raw signature. + (r, s) = decode_dss_signature(asn1_signature) + return r.to_bytes(self._attributes.rs_size, byteorder="big") + s.to_bytes( + self._attributes.rs_size, byteorder="big" + ) + + @classmethod + def from_string( + cls, key: Union[bytes, str], key_id: Optional[str] = None + ) -> "EsSigner": + """Construct a RSASigner from a private key in PEM format. + + Args: + key (Union[bytes, str]): Private key in PEM format. + key_id (str): An optional key id used to identify the private key. + + Returns: + google.auth.crypt._cryptography_rsa.RSASigner: The + constructed signer. + + Raises: + ValueError: If ``key`` is not ``bytes`` or ``str`` (unicode). + UnicodeDecodeError: If ``key`` is ``bytes`` but cannot be decoded + into a UTF-8 ``str``. + ValueError: If ``cryptography`` "Could not deserialize key data." + """ + key_bytes = _helpers.to_bytes(key) + private_key = serialization.load_pem_private_key( + key_bytes, password=None, backend=_BACKEND + ) + + if not isinstance(private_key, ec.EllipticCurvePrivateKey): + raise TypeError("Expected private key of type EllipticCurvePrivateKey") + + return cls(private_key, key_id=key_id) + + def __getstate__(self) -> Dict[str, Any]: + """Pickle helper that serializes the _key attribute.""" + state = self.__dict__.copy() + state["_key"] = self._key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + return state + + def __setstate__(self, state: Dict[str, Any]) -> None: + """Pickle helper that deserializes the _key attribute.""" + state["_key"] = serialization.load_pem_private_key(state["_key"], None) + self.__dict__.update(state) diff --git a/packages/google-auth/google/auth/crypt/es256.py b/packages/google-auth/google/auth/crypt/es256.py new file mode 100644 index 000000000000..e7bda5d3fc29 --- /dev/null +++ b/packages/google-auth/google/auth/crypt/es256.py @@ -0,0 +1,45 @@ +# Copyright 2017 Google Inc. +# +# 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. + +"""ECDSA (ES256) verifier and signer that use the ``cryptography`` library. +""" + +from google.auth.crypt.es import EsSigner +from google.auth.crypt.es import EsVerifier + + +class ES256Verifier(EsVerifier): + """Verifies ECDSA cryptographic signatures using public keys. + + Args: + public_key (cryptography.hazmat.primitives.asymmetric.ec.ECDSAPublicKey): The public key used to verify + signatures. + """ + + pass + + +class ES256Signer(EsSigner): + """Signs messages with an ECDSA private key. + + Args: + private_key ( + cryptography.hazmat.primitives.asymmetric.ec.ECDSAPrivateKey): + The private key to sign with. + key_id (str): Optional key ID used to identify this private key. This + can be useful to associate the private key with its associated + public key or certificate. + """ + + pass diff --git a/packages/google-auth/google/auth/crypt/rsa.py b/packages/google-auth/google/auth/crypt/rsa.py new file mode 100644 index 000000000000..ed842d1eb8ef --- /dev/null +++ b/packages/google-auth/google/auth/crypt/rsa.py @@ -0,0 +1,30 @@ +# Copyright 2017 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. + +"""RSA cryptography signer and verifier.""" + + +try: + # Prefer cryptograph-based RSA implementation. + from google.auth.crypt import _cryptography_rsa + + RSASigner = _cryptography_rsa.RSASigner + RSAVerifier = _cryptography_rsa.RSAVerifier +except ImportError: # pragma: NO COVER + # Fallback to pure-python RSA implementation if cryptography is + # unavailable. + from google.auth.crypt import _python_rsa + + RSASigner = _python_rsa.RSASigner # type: ignore + RSAVerifier = _python_rsa.RSAVerifier # type: ignore diff --git a/packages/google-auth/google/auth/downscoped.py b/packages/google-auth/google/auth/downscoped.py new file mode 100644 index 000000000000..ea75be90fe4e --- /dev/null +++ b/packages/google-auth/google/auth/downscoped.py @@ -0,0 +1,512 @@ +# Copyright 2021 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. + +"""Downscoping with Credential Access Boundaries + +This module provides the ability to downscope credentials using +`Downscoping with Credential Access Boundaries`_. This is useful to restrict the +Identity and Access Management (IAM) permissions that a short-lived credential +can use. + +To downscope permissions of a source credential, a Credential Access Boundary +that specifies which resources the new credential can access, as well as +an upper bound on the permissions that are available on each resource, has to +be defined. A downscoped credential can then be instantiated using the source +credential and the Credential Access Boundary. + +The common pattern of usage is to have a token broker with elevated access +generate these downscoped credentials from higher access source credentials and +pass the downscoped short-lived access tokens to a token consumer via some +secure authenticated channel for limited access to Google Cloud Storage +resources. + +For example, a token broker can be set up on a server in a private network. +Various workloads (token consumers) in the same network will send authenticated +requests to that broker for downscoped tokens to access or modify specific google +cloud storage buckets. + +The broker will instantiate downscoped credentials instances that can be used to +generate short lived downscoped access tokens that can be passed to the token +consumer. These downscoped access tokens can be injected by the consumer into +google.oauth2.Credentials and used to initialize a storage client instance to +access Google Cloud Storage resources with restricted access. + +Note: Only Cloud Storage supports Credential Access Boundaries. Other Google +Cloud services do not support this feature. + +.. _Downscoping with Credential Access Boundaries: https://cloud.google.com/iam/docs/downscoping-short-lived-credentials +""" + +import datetime + +from google.auth import _helpers +from google.auth import credentials +from google.auth import exceptions +from google.oauth2 import sts + +# The maximum number of access boundary rules a Credential Access Boundary can +# contain. +_MAX_ACCESS_BOUNDARY_RULES_COUNT = 10 +# The token exchange grant_type used for exchanging credentials. +_STS_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange" +# The token exchange requested_token_type. This is always an access_token. +_STS_REQUESTED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token" +# The STS token URL used to exchanged a short lived access token for a downscoped one. +_STS_TOKEN_URL_PATTERN = "https://sts.{}/v1/token" +# The subject token type to use when exchanging a short lived access token for a +# downscoped token. +_STS_SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token" + + +class CredentialAccessBoundary(object): + """Defines a Credential Access Boundary which contains a list of access boundary + rules. Each rule contains information on the resource that the rule applies to, + the upper bound of the permissions that are available on that resource and an + optional condition to further restrict permissions. + """ + + def __init__(self, rules=[]): + """Instantiates a Credential Access Boundary. A Credential Access Boundary + can contain up to 10 access boundary rules. + + Args: + rules (Sequence[google.auth.downscoped.AccessBoundaryRule]): The list of + access boundary rules limiting the access that a downscoped credential + will have. + Raises: + InvalidType: If any of the rules are not a valid type. + InvalidValue: If the provided rules exceed the maximum allowed. + """ + self.rules = rules + + @property + def rules(self): + """Returns the list of access boundary rules defined on the Credential + Access Boundary. + + Returns: + Tuple[google.auth.downscoped.AccessBoundaryRule, ...]: The list of access + boundary rules defined on the Credential Access Boundary. These are returned + as an immutable tuple to prevent modification. + """ + return tuple(self._rules) + + @rules.setter + def rules(self, value): + """Updates the current rules on the Credential Access Boundary. This will overwrite + the existing set of rules. + + Args: + value (Sequence[google.auth.downscoped.AccessBoundaryRule]): The list of + access boundary rules limiting the access that a downscoped credential + will have. + Raises: + InvalidType: If any of the rules are not a valid type. + InvalidValue: If the provided rules exceed the maximum allowed. + """ + if len(value) > _MAX_ACCESS_BOUNDARY_RULES_COUNT: + raise exceptions.InvalidValue( + "Credential access boundary rules can have a maximum of {} rules.".format( + _MAX_ACCESS_BOUNDARY_RULES_COUNT + ) + ) + for access_boundary_rule in value: + if not isinstance(access_boundary_rule, AccessBoundaryRule): + raise exceptions.InvalidType( + "List of rules provided do not contain a valid 'google.auth.downscoped.AccessBoundaryRule'." + ) + # Make a copy of the original list. + self._rules = list(value) + + def add_rule(self, rule): + """Adds a single access boundary rule to the existing rules. + + Args: + rule (google.auth.downscoped.AccessBoundaryRule): The access boundary rule, + limiting the access that a downscoped credential will have, to be added to + the existing rules. + Raises: + InvalidType: If any of the rules are not a valid type. + InvalidValue: If the provided rules exceed the maximum allowed. + """ + if len(self.rules) == _MAX_ACCESS_BOUNDARY_RULES_COUNT: + raise exceptions.InvalidValue( + "Credential access boundary rules can have a maximum of {} rules.".format( + _MAX_ACCESS_BOUNDARY_RULES_COUNT + ) + ) + if not isinstance(rule, AccessBoundaryRule): + raise exceptions.InvalidType( + "The provided rule does not contain a valid 'google.auth.downscoped.AccessBoundaryRule'." + ) + self._rules.append(rule) + + def to_json(self): + """Generates the dictionary representation of the Credential Access Boundary. + This uses the format expected by the Security Token Service API as documented in + `Defining a Credential Access Boundary`_. + + .. _Defining a Credential Access Boundary: + https://cloud.google.com/iam/docs/downscoping-short-lived-credentials#define-boundary + + Returns: + Mapping: Credential Access Boundary Rule represented in a dictionary object. + """ + rules = [] + for access_boundary_rule in self.rules: + rules.append(access_boundary_rule.to_json()) + + return {"accessBoundary": {"accessBoundaryRules": rules}} + + +class AccessBoundaryRule(object): + """Defines an access boundary rule which contains information on the resource that + the rule applies to, the upper bound of the permissions that are available on that + resource and an optional condition to further restrict permissions. + """ + + def __init__( + self, available_resource, available_permissions, availability_condition=None + ): + """Instantiates a single access boundary rule. + + Args: + available_resource (str): The full resource name of the Cloud Storage bucket + that the rule applies to. Use the format + "//storage.googleapis.com/projects/_/buckets/bucket-name". + available_permissions (Sequence[str]): A list defining the upper bound that + the downscoped token will have on the available permissions for the + resource. Each value is the identifier for an IAM predefined role or + custom role, with the prefix "inRole:". For example: + "inRole:roles/storage.objectViewer". + Only the permissions in these roles will be available. + availability_condition (Optional[google.auth.downscoped.AvailabilityCondition]): + Optional condition that restricts the availability of permissions to + specific Cloud Storage objects. + + Raises: + InvalidType: If any of the parameters are not of the expected types. + InvalidValue: If any of the parameters are not of the expected values. + """ + self.available_resource = available_resource + self.available_permissions = available_permissions + self.availability_condition = availability_condition + + @property + def available_resource(self): + """Returns the current available resource. + + Returns: + str: The current available resource. + """ + return self._available_resource + + @available_resource.setter + def available_resource(self, value): + """Updates the current available resource. + + Args: + value (str): The updated value of the available resource. + + Raises: + google.auth.exceptions.InvalidType: If the value is not a string. + """ + if not isinstance(value, str): + raise exceptions.InvalidType( + "The provided available_resource is not a string." + ) + self._available_resource = value + + @property + def available_permissions(self): + """Returns the current available permissions. + + Returns: + Tuple[str, ...]: The current available permissions. These are returned + as an immutable tuple to prevent modification. + """ + return tuple(self._available_permissions) + + @available_permissions.setter + def available_permissions(self, value): + """Updates the current available permissions. + + Args: + value (Sequence[str]): The updated value of the available permissions. + + Raises: + InvalidType: If the value is not a list of strings. + InvalidValue: If the value is not valid. + """ + for available_permission in value: + if not isinstance(available_permission, str): + raise exceptions.InvalidType( + "Provided available_permissions are not a list of strings." + ) + if available_permission.find("inRole:") != 0: + raise exceptions.InvalidValue( + "available_permissions must be prefixed with 'inRole:'." + ) + # Make a copy of the original list. + self._available_permissions = list(value) + + @property + def availability_condition(self): + """Returns the current availability condition. + + Returns: + Optional[google.auth.downscoped.AvailabilityCondition]: The current + availability condition. + """ + return self._availability_condition + + @availability_condition.setter + def availability_condition(self, value): + """Updates the current availability condition. + + Args: + value (Optional[google.auth.downscoped.AvailabilityCondition]): The updated + value of the availability condition. + + Raises: + google.auth.exceptions.InvalidType: If the value is not of type google.auth.downscoped.AvailabilityCondition + or None. + """ + if not isinstance(value, AvailabilityCondition) and value is not None: + raise exceptions.InvalidType( + "The provided availability_condition is not a 'google.auth.downscoped.AvailabilityCondition' or None." + ) + self._availability_condition = value + + def to_json(self): + """Generates the dictionary representation of the access boundary rule. + This uses the format expected by the Security Token Service API as documented in + `Defining a Credential Access Boundary`_. + + .. _Defining a Credential Access Boundary: + https://cloud.google.com/iam/docs/downscoping-short-lived-credentials#define-boundary + + Returns: + Mapping: The access boundary rule represented in a dictionary object. + """ + json = { + "availablePermissions": list(self.available_permissions), + "availableResource": self.available_resource, + } + if self.availability_condition: + json["availabilityCondition"] = self.availability_condition.to_json() + return json + + +class AvailabilityCondition(object): + """An optional condition that can be used as part of a Credential Access Boundary + to further restrict permissions.""" + + def __init__(self, expression, title=None, description=None): + """Instantiates an availability condition using the provided expression and + optional title or description. + + Args: + expression (str): A condition expression that specifies the Cloud Storage + objects where permissions are available. For example, this expression + makes permissions available for objects whose name starts with "customer-a": + "resource.name.startsWith('projects/_/buckets/example-bucket/objects/customer-a')" + title (Optional[str]): An optional short string that identifies the purpose of + the condition. + description (Optional[str]): Optional details about the purpose of the condition. + + Raises: + InvalidType: If any of the parameters are not of the expected types. + InvalidValue: If any of the parameters are not of the expected values. + """ + self.expression = expression + self.title = title + self.description = description + + @property + def expression(self): + """Returns the current condition expression. + + Returns: + str: The current conditon expression. + """ + return self._expression + + @expression.setter + def expression(self, value): + """Updates the current condition expression. + + Args: + value (str): The updated value of the condition expression. + + Raises: + google.auth.exceptions.InvalidType: If the value is not of type string. + """ + if not isinstance(value, str): + raise exceptions.InvalidType("The provided expression is not a string.") + self._expression = value + + @property + def title(self): + """Returns the current title. + + Returns: + Optional[str]: The current title. + """ + return self._title + + @title.setter + def title(self, value): + """Updates the current title. + + Args: + value (Optional[str]): The updated value of the title. + + Raises: + google.auth.exceptions.InvalidType: If the value is not of type string or None. + """ + if not isinstance(value, str) and value is not None: + raise exceptions.InvalidType("The provided title is not a string or None.") + self._title = value + + @property + def description(self): + """Returns the current description. + + Returns: + Optional[str]: The current description. + """ + return self._description + + @description.setter + def description(self, value): + """Updates the current description. + + Args: + value (Optional[str]): The updated value of the description. + + Raises: + google.auth.exceptions.InvalidType: If the value is not of type string or None. + """ + if not isinstance(value, str) and value is not None: + raise exceptions.InvalidType( + "The provided description is not a string or None." + ) + self._description = value + + def to_json(self): + """Generates the dictionary representation of the availability condition. + This uses the format expected by the Security Token Service API as documented in + `Defining a Credential Access Boundary`_. + + .. _Defining a Credential Access Boundary: + https://cloud.google.com/iam/docs/downscoping-short-lived-credentials#define-boundary + + Returns: + Mapping[str, str]: The availability condition represented in a dictionary + object. + """ + json = {"expression": self.expression} + if self.title: + json["title"] = self.title + if self.description: + json["description"] = self.description + return json + + +class Credentials(credentials.CredentialsWithQuotaProject): + """Defines a set of Google credentials that are downscoped from an existing set + of Google OAuth2 credentials. This is useful to restrict the Identity and Access + Management (IAM) permissions that a short-lived credential can use. + The common pattern of usage is to have a token broker with elevated access + generate these downscoped credentials from higher access source credentials and + pass the downscoped short-lived access tokens to a token consumer via some + secure authenticated channel for limited access to Google Cloud Storage + resources. + """ + + def __init__( + self, + source_credentials, + credential_access_boundary, + quota_project_id=None, + universe_domain=credentials.DEFAULT_UNIVERSE_DOMAIN, + ): + """Instantiates a downscoped credentials object using the provided source + credentials and credential access boundary rules. + To downscope permissions of a source credential, a Credential Access Boundary + that specifies which resources the new credential can access, as well as an + upper bound on the permissions that are available on each resource, has to be + defined. A downscoped credential can then be instantiated using the source + credential and the Credential Access Boundary. + + Args: + source_credentials (google.auth.credentials.Credentials): The source credentials + to be downscoped based on the provided Credential Access Boundary rules. + credential_access_boundary (google.auth.downscoped.CredentialAccessBoundary): + The Credential Access Boundary which contains a list of access boundary + rules. Each rule contains information on the resource that the rule applies to, + the upper bound of the permissions that are available on that resource and an + optional condition to further restrict permissions. + quota_project_id (Optional[str]): The optional quota project ID. + universe_domain (Optional[str]): The universe domain value, default is googleapis.com + Raises: + google.auth.exceptions.RefreshError: If the source credentials + return an error on token refresh. + google.auth.exceptions.OAuthError: If the STS token exchange + endpoint returned an error during downscoped token generation. + """ + + super(Credentials, self).__init__() + self._source_credentials = source_credentials + self._credential_access_boundary = credential_access_boundary + self._quota_project_id = quota_project_id + self._universe_domain = universe_domain or credentials.DEFAULT_UNIVERSE_DOMAIN + self._sts_client = sts.Client( + _STS_TOKEN_URL_PATTERN.format(self.universe_domain) + ) + + @_helpers.copy_docstring(credentials.Credentials) + def refresh(self, request): + # Generate an access token from the source credentials. + self._source_credentials.refresh(request) + now = _helpers.utcnow() + # Exchange the access token for a downscoped access token. + response_data = self._sts_client.exchange_token( + request=request, + grant_type=_STS_GRANT_TYPE, + subject_token=self._source_credentials.token, + subject_token_type=_STS_SUBJECT_TOKEN_TYPE, + requested_token_type=_STS_REQUESTED_TOKEN_TYPE, + additional_options=self._credential_access_boundary.to_json(), + ) + self.token = response_data.get("access_token") + # 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): + return self.__class__( + self._source_credentials, + self._credential_access_boundary, + quota_project_id=quota_project_id, + ) diff --git a/packages/google-auth/google/auth/environment_vars.py b/packages/google-auth/google/auth/environment_vars.py new file mode 100644 index 000000000000..5da3a7382391 --- /dev/null +++ b/packages/google-auth/google/auth/environment_vars.py @@ -0,0 +1,94 @@ +# Copyright 2016 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. + +"""Environment variables used by :mod:`google.auth`.""" + + +PROJECT = "GOOGLE_CLOUD_PROJECT" +"""Environment variable defining default project. + +This used by :func:`google.auth.default` to explicitly set a project ID. This +environment variable is also used by the Google Cloud Python Library. +""" + +LEGACY_PROJECT = "GCLOUD_PROJECT" +"""Previously used environment variable defining the default project. + +This environment variable is used instead of the current one in some +situations (such as Google App Engine). +""" + +GOOGLE_CLOUD_QUOTA_PROJECT = "GOOGLE_CLOUD_QUOTA_PROJECT" +"""Environment variable defining the project to be used for +quota and billing.""" + +CREDENTIALS = "GOOGLE_APPLICATION_CREDENTIALS" +"""Environment variable defining the location of Google application default +credentials.""" + +# The environment variable name which can replace ~/.config if set. +CLOUD_SDK_CONFIG_DIR = "CLOUDSDK_CONFIG" +"""Environment variable defines the location of Google Cloud SDK's config +files.""" + +# These two variables allow for customization of the addresses used when +# contacting the GCE metadata service. +GCE_METADATA_HOST = "GCE_METADATA_HOST" +"""Environment variable providing an alternate hostname or host:port to be +used for GCE metadata requests. + +This environment variable was originally named GCE_METADATA_ROOT. The system will +check this environemnt variable first; should there be no value present, +the system will fall back to the old variable. +""" + +GCE_METADATA_ROOT = "GCE_METADATA_ROOT" +"""Old environment variable for GCE_METADATA_HOST.""" + +GCE_METADATA_IP = "GCE_METADATA_IP" +"""Environment variable providing an alternate ip:port to be used for ip-only +GCE metadata requests.""" + +GCE_METADATA_MTLS_MODE = "GCE_METADATA_MTLS_MODE" +"""Environment variable controlling the mTLS behavior for GCE metadata requests. + +Can be one of "strict", "none", or "default". +""" + +GOOGLE_API_USE_CLIENT_CERTIFICATE = "GOOGLE_API_USE_CLIENT_CERTIFICATE" +"""Environment variable controlling whether to use client certificate or not. + +The default value is false. Users have to explicitly set this value to true +in order to use client certificate to establish a mutual TLS channel.""" + +LEGACY_APPENGINE_RUNTIME = "APPENGINE_RUNTIME" +"""Gen1 environment variable defining the App Engine Runtime. + +Used to distinguish between GAE gen1 and GAE gen2+. +""" + +# AWS environment variables used with AWS workload identity pools to retrieve +# AWS security credentials and the AWS region needed to create a serialized +# signed requests to the AWS STS GetCalledIdentity API that can be exchanged +# for a Google access tokens via the GCP STS endpoint. +# When not available the AWS metadata server is used to retrieve these values. +AWS_ACCESS_KEY_ID = "AWS_ACCESS_KEY_ID" +AWS_SECRET_ACCESS_KEY = "AWS_SECRET_ACCESS_KEY" +AWS_SESSION_TOKEN = "AWS_SESSION_TOKEN" +AWS_REGION = "AWS_REGION" +AWS_DEFAULT_REGION = "AWS_DEFAULT_REGION" + +GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED = "GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED" +"""Environment variable controlling whether to enable trust boundary feature. +The default value is false. Users have to explicitly set this value to true.""" diff --git a/packages/google-auth/google/auth/exceptions.py b/packages/google-auth/google/auth/exceptions.py new file mode 100644 index 000000000000..feb9f7411e04 --- /dev/null +++ b/packages/google-auth/google/auth/exceptions.py @@ -0,0 +1,108 @@ +# Copyright 2016 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. + +"""Exceptions used in the google.auth package.""" + + +class GoogleAuthError(Exception): + """Base class for all google.auth errors.""" + + def __init__(self, *args, **kwargs): + super(GoogleAuthError, self).__init__(*args) + retryable = kwargs.get("retryable", False) + self._retryable = retryable + + @property + def retryable(self): + return self._retryable + + +class TransportError(GoogleAuthError): + """Used to indicate an error occurred during an HTTP request.""" + + +class RefreshError(GoogleAuthError): + """Used to indicate that an refreshing the credentials' access token + failed.""" + + +class UserAccessTokenError(GoogleAuthError): + """Used to indicate ``gcloud auth print-access-token`` command failed.""" + + +class DefaultCredentialsError(GoogleAuthError): + """Used to indicate that acquiring default credentials failed.""" + + +class MutualTLSChannelError(GoogleAuthError): + """Used to indicate that mutual TLS channel creation is failed, or mutual + TLS channel credentials is missing or invalid.""" + + +class ClientCertError(GoogleAuthError): + """Used to indicate that client certificate is missing or invalid.""" + + @property + def retryable(self): + return False + + +class OAuthError(GoogleAuthError): + """Used to indicate an error occurred during an OAuth related HTTP + request.""" + + +class ReauthFailError(RefreshError): + """An exception for when reauth failed.""" + + def __init__(self, message=None, **kwargs): + super(ReauthFailError, self).__init__( + "Reauthentication failed. {0}".format(message), **kwargs + ) + + +class ReauthSamlChallengeFailError(ReauthFailError): + """An exception for SAML reauth challenge failures.""" + + +class MalformedError(DefaultCredentialsError, ValueError): + """An exception for malformed data.""" + + +class InvalidResource(DefaultCredentialsError, ValueError): + """An exception for URL error.""" + + +class InvalidOperation(DefaultCredentialsError, ValueError): + """An exception for invalid operation.""" + + +class InvalidValue(DefaultCredentialsError, ValueError): + """Used to wrap general ValueError of python.""" + + +class InvalidType(DefaultCredentialsError, TypeError): + """Used to wrap general TypeError of python.""" + + +class OSError(DefaultCredentialsError, EnvironmentError): + """Used to wrap EnvironmentError(OSError after python3.3).""" + + +class TimeoutError(GoogleAuthError): + """Used to indicate a timeout error occurred during an HTTP request.""" + + +class ResponseError(GoogleAuthError): + """Used to indicate an error occurred when reading an HTTP response.""" diff --git a/packages/google-auth/google/auth/external_account.py b/packages/google-auth/google/auth/external_account.py new file mode 100644 index 000000000000..06536d963523 --- /dev/null +++ b/packages/google-auth/google/auth/external_account.py @@ -0,0 +1,709 @@ +# 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. + +"""External Account Credentials. + +This module provides credentials that exchange workload identity pool external +credentials for Google access tokens. This facilitates accessing Google Cloud +Platform resources from on-prem and non-Google Cloud platforms (e.g. AWS, +Microsoft Azure, OIDC identity providers), using native credentials retrieved +from the current environment without the need to copy, save and manage +long-lived service account credentials. + +Specifically, this is intended to use access tokens acquired using the GCP STS +token exchange endpoint following the `OAuth 2.0 Token Exchange`_ spec. + +.. _OAuth 2.0 Token Exchange: https://tools.ietf.org/html/rfc8693 +""" + +import abc +import copy +from dataclasses import dataclass +import datetime +import functools +import io +import json +import re + +from google.auth import _constants +from google.auth import _helpers +from google.auth import credentials +from google.auth import exceptions +from google.auth import impersonated_credentials +from google.auth import metrics +from google.oauth2 import sts +from google.oauth2 import utils + +# External account JSON type identifier. +_EXTERNAL_ACCOUNT_JSON_TYPE = "external_account" +# The token exchange grant_type used for exchanging credentials. +_STS_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange" +# The token exchange requested_token_type. This is always an access_token. +_STS_REQUESTED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token" +# Cloud resource manager URL used to retrieve project information. +_CLOUD_RESOURCE_MANAGER = "https://cloudresourcemanager.googleapis.com/v1/projects/" +# Default Google sts token url. +_DEFAULT_TOKEN_URL = "https://sts.{universe_domain}/v1/token" + + +@dataclass +class SupplierContext: + """A context class that contains information about the requested third party credential that is passed + to AWS security credential and subject token suppliers. + + Attributes: + subject_token_type (str): The requested subject token type based on the Oauth2.0 token exchange spec. + Expected values include:: + + “urn:ietf:params:oauth:token-type:jwt” + “urn:ietf:params:oauth:token-type:id-token” + “urn:ietf:params:oauth:token-type:saml2” + “urn:ietf:params:aws:token-type:aws4_request” + + audience (str): The requested audience for the subject token. + """ + + subject_token_type: str + audience: str + + +class Credentials( + credentials.Scoped, + credentials.CredentialsWithQuotaProject, + credentials.CredentialsWithTokenUri, + credentials.CredentialsWithTrustBoundary, + metaclass=abc.ABCMeta, +): + """Base class for all external account credentials. + + This is used to instantiate Credentials for exchanging external account + credentials for Google access token and authorizing requests to Google APIs. + The base class implements the common logic for exchanging external account + credentials for Google access tokens. + + **IMPORTANT**: + This class does not validate the credential configuration. A security + risk occurs when a credential configuration configured with malicious urls + is used. + When the credential configuration is accepted from an + untrusted source, you should validate it before using. + Refer https://cloud.google.com/docs/authentication/external/externally-sourced-credentials for more details. + """ + + def __init__( + self, + audience, + subject_token_type, + token_url, + credential_source, + service_account_impersonation_url=None, + service_account_impersonation_options=None, + client_id=None, + client_secret=None, + token_info_url=None, + quota_project_id=None, + scopes=None, + default_scopes=None, + workforce_pool_user_project=None, + universe_domain=credentials.DEFAULT_UNIVERSE_DOMAIN, + trust_boundary=None, + ): + """Instantiates an external account credentials object. + + Args: + audience (str): The STS audience field. + subject_token_type (str): The subject token type based on the Oauth2.0 token exchange spec. + Expected values include:: + + “urn:ietf:params:oauth:token-type:jwt” + “urn:ietf:params:oauth:token-type:id-token” + “urn:ietf:params:oauth:token-type:saml2” + “urn:ietf:params:aws:token-type:aws4_request” + + token_url (str): The STS endpoint URL. + credential_source (Mapping): The credential source dictionary. + service_account_impersonation_url (Optional[str]): The optional service account + impersonation generateAccessToken URL. + client_id (Optional[str]): The optional client ID. + client_secret (Optional[str]): The optional client secret. + token_info_url (str): The optional STS endpoint URL for token introspection. + quota_project_id (Optional[str]): The optional quota project ID. + scopes (Optional[Sequence[str]]): Optional scopes to request during the + authorization grant. + default_scopes (Optional[Sequence[str]]): Default scopes passed by a + Google client library. Use 'scopes' for user-defined scopes. + workforce_pool_user_project (Optona[str]): The optional workforce pool user + project number when the credential corresponds to a workforce pool and not + a workload identity pool. The underlying principal must still have + serviceusage.services.use IAM permission to use the project for + billing/quota. + universe_domain (str): The universe domain. The default universe + domain is googleapis.com. + trust_boundary (str): String representation of trust boundary meta. + Raises: + google.auth.exceptions.RefreshError: If the generateAccessToken + endpoint returned an error. + """ + super(Credentials, self).__init__() + self._audience = audience + self._subject_token_type = subject_token_type + self._universe_domain = universe_domain + self._token_url = token_url + if self._token_url == _DEFAULT_TOKEN_URL: + self._token_url = self._token_url.replace( + "{universe_domain}", self._universe_domain + ) + self._token_info_url = token_info_url + self._credential_source = credential_source + self._service_account_impersonation_url = service_account_impersonation_url + self._service_account_impersonation_options = ( + service_account_impersonation_options or {} + ) + self._client_id = client_id + self._client_secret = client_secret + self._quota_project_id = quota_project_id + self._scopes = scopes + self._default_scopes = default_scopes + self._workforce_pool_user_project = workforce_pool_user_project + self._trust_boundary = trust_boundary + + if self._client_id: + self._client_auth = utils.ClientAuthentication( + utils.ClientAuthType.basic, self._client_id, self._client_secret + ) + else: + self._client_auth = None + self._sts_client = sts.Client(self._token_url, self._client_auth) + + self._metrics_options = self._create_default_metrics_options() + + self._impersonated_credentials = None + self._project_id = None + self._supplier_context = SupplierContext( + self._subject_token_type, self._audience + ) + self._cred_file_path = None + + if not self.is_workforce_pool and self._workforce_pool_user_project: + # Workload identity pools do not support workforce pool user projects. + raise exceptions.InvalidValue( + "workforce_pool_user_project should not be set for non-workforce pool " + "credentials" + ) + + @property + def info(self): + """Generates the dictionary representation of the current credentials. + + Returns: + Mapping: The dictionary representation of the credentials. This is the + reverse of "from_info" defined on the subclasses of this class. It is + useful for serializing the current credentials so it can deserialized + later. + """ + config_info = self._constructor_args() + config_info.update( + type=_EXTERNAL_ACCOUNT_JSON_TYPE, + service_account_impersonation=config_info.pop( + "service_account_impersonation_options", None + ), + ) + config_info.pop("scopes", None) + config_info.pop("default_scopes", None) + return {key: value for key, value in config_info.items() if value is not None} + + def _constructor_args(self): + args = { + "audience": self._audience, + "subject_token_type": self._subject_token_type, + "token_url": self._token_url, + "token_info_url": self._token_info_url, + "service_account_impersonation_url": self._service_account_impersonation_url, + "service_account_impersonation_options": copy.deepcopy( + self._service_account_impersonation_options + ) + or None, + "credential_source": copy.deepcopy(self._credential_source), + "quota_project_id": self._quota_project_id, + "client_id": self._client_id, + "client_secret": self._client_secret, + "workforce_pool_user_project": self._workforce_pool_user_project, + "scopes": self._scopes, + "default_scopes": self._default_scopes, + "universe_domain": self._universe_domain, + "trust_boundary": self._trust_boundary, + } + if not self.is_workforce_pool: + args.pop("workforce_pool_user_project") + return args + + @property + def service_account_email(self): + """Returns the service account email if service account impersonation is used. + + Returns: + Optional[str]: The service account email if impersonation is used. Otherwise + None is returned. + """ + if self._service_account_impersonation_url: + # Parse email from URL. The formal looks as follows: + # https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/name@project-id.iam.gserviceaccount.com:generateAccessToken + url = self._service_account_impersonation_url + start_index = url.rfind("/") + end_index = url.find(":generateAccessToken") + if start_index != -1 and end_index != -1 and start_index < end_index: + start_index = start_index + 1 + return url[start_index:end_index] + return None + + @property + def is_user(self): + """Returns whether the credentials represent a user (True) or workload (False). + Workloads behave similarly to service accounts. Currently workloads will use + service account impersonation but will eventually not require impersonation. + As a result, this property is more reliable than the service account email + property in determining if the credentials represent a user or workload. + + Returns: + bool: True if the credentials represent a user. False if they represent a + workload. + """ + # If service account impersonation is used, the credentials will always represent a + # service account. + if self._service_account_impersonation_url: + return False + return self.is_workforce_pool + + @property + def is_workforce_pool(self): + """Returns whether the credentials represent a workforce pool (True) or + workload (False) based on the credentials' audience. + + This will also return True for impersonated workforce pool credentials. + + Returns: + bool: True if the credentials represent a workforce pool. False if they + represent a workload. + """ + # Workforce pools representing users have the following audience format: + # //iam.googleapis.com/locations/$location/workforcePools/$poolId/providers/$providerId + p = re.compile(r"//iam\.googleapis\.com/locations/[^/]+/workforcePools/") + return p.match(self._audience or "") is not None + + @property + def requires_scopes(self): + """Checks if the credentials requires scopes. + + Returns: + bool: True if there are no scopes set otherwise False. + """ + return not self._scopes and not self._default_scopes + + @property + def project_number(self): + """Optional[str]: The project number corresponding to the workload identity pool.""" + + # STS audience pattern: + # //iam.googleapis.com/projects/$PROJECT_NUMBER/locations/... + components = self._audience.split("/") + try: + project_index = components.index("projects") + if project_index + 1 < len(components): + return components[project_index + 1] or None + except ValueError: + return None + + @property + def token_info_url(self): + """Optional[str]: The STS token introspection endpoint.""" + + return self._token_info_url + + @_helpers.copy_docstring(credentials.Credentials) + def get_cred_info(self): + if self._cred_file_path: + cred_info_json = { + "credential_source": self._cred_file_path, + "credential_type": "external account credentials", + } + if self.service_account_email: + cred_info_json["principal"] = self.service_account_email + return cred_info_json + return None + + @_helpers.copy_docstring(credentials.Scoped) + def with_scopes(self, scopes, default_scopes=None): + kwargs = self._constructor_args() + kwargs.update(scopes=scopes, default_scopes=default_scopes) + scoped = self.__class__(**kwargs) + scoped._cred_file_path = self._cred_file_path + scoped._metrics_options = self._metrics_options + return scoped + + @abc.abstractmethod + def retrieve_subject_token(self, request): + """Retrieves the subject token using the credential_source object. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + Returns: + str: The retrieved subject token. + """ + # pylint: disable=missing-raises-doc + # (pylint doesn't recognize that this is abstract) + raise NotImplementedError("retrieve_subject_token must be implemented") + + def get_project_id(self, request): + """Retrieves the project ID corresponding to the workload identity or workforce pool. + For workforce pool credentials, it returns the project ID corresponding to + the workforce_pool_user_project. + + When not determinable, None is returned. + + This is introduced to support the current pattern of using the Auth library: + + credentials, project_id = google.auth.default() + + The resource may not have permission (resourcemanager.projects.get) to + call this API or the required scopes may not be selected: + https://cloud.google.com/resource-manager/reference/rest/v1/projects/get#authorization-scopes + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + Returns: + Optional[str]: The project ID corresponding to the workload identity pool + or workforce pool if determinable. + """ + if self._project_id: + # If already retrieved, return the cached project ID value. + return self._project_id + scopes = self._scopes if self._scopes is not None else self._default_scopes + # Scopes are required in order to retrieve a valid access token. + project_number = self.project_number or self._workforce_pool_user_project + if project_number and scopes: + headers = {} + url = _CLOUD_RESOURCE_MANAGER + project_number + self.before_request(request, "GET", url, headers) + response = request(url=url, method="GET", headers=headers) + + response_body = ( + response.data.decode("utf-8") + if hasattr(response.data, "decode") + else response.data + ) + response_data = json.loads(response_body) + + if response.status == 200: + # Cache result as this field is immutable. + self._project_id = response_data.get("projectId") + return self._project_id + + return None + + def refresh(self, request): + """Refreshes the access token. + + For impersonated credentials, this method will refresh the underlying + source credentials and the impersonated credentials. For non-impersonated + credentials, it will refresh the access token and the trust boundary. + """ + self._refresh_token(request) + # If we are impersonating, the trust boundary is handled by the + # impersonated credentials object. We need to get it from there. + if self._service_account_impersonation_url: + self._trust_boundary = self._impersonated_credentials._trust_boundary + else: + # Otherwise, refresh the trust boundary for the external account. + self._refresh_trust_boundary(request) + + def _refresh_token(self, request): + scopes = self._scopes if self._scopes is not None else self._default_scopes + + # Inject client certificate into request. + if self._mtls_required(): + request = functools.partial( + request, cert=self._get_mtls_cert_and_key_paths() + ) + + if self._should_initialize_impersonated_credentials(): + self._impersonated_credentials = self._initialize_impersonated_credentials() + + if self._impersonated_credentials: + self._impersonated_credentials.refresh(request) + self.token = self._impersonated_credentials.token + self.expiry = self._impersonated_credentials.expiry + else: + now = _helpers.utcnow() + additional_options = None + # Do not pass workforce_pool_user_project when client authentication + # is used. The client ID is sufficient for determining the user project. + if self._workforce_pool_user_project and not self._client_id: + additional_options = {"userProject": self._workforce_pool_user_project} + additional_headers = { + metrics.API_CLIENT_HEADER: metrics.byoid_metrics_header( + self._metrics_options + ) + } + response_data = self._sts_client.exchange_token( + request=request, + grant_type=_STS_GRANT_TYPE, + subject_token=self.retrieve_subject_token(request), + subject_token_type=self._subject_token_type, + audience=self._audience, + scopes=scopes, + requested_token_type=_STS_REQUESTED_TOKEN_TYPE, + additional_options=additional_options, + additional_headers=additional_headers, + ) + self.token = response_data.get("access_token") + expires_in = response_data.get("expires_in") + # Some services do not respect the OAUTH2.0 RFC and send expires_in as a + # JSON String. + if isinstance(expires_in, str): + expires_in = int(expires_in) + + lifetime = datetime.timedelta(seconds=expires_in) + + self.expiry = now + lifetime + + def _build_trust_boundary_lookup_url(self): + """Builds and returns the URL for the trust boundary lookup API.""" + url = None + # Try to parse as a workload identity pool. + # Audience format: //iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID + workload_match = re.search( + r"projects/([^/]+)/locations/global/workloadIdentityPools/([^/]+)", + self._audience, + ) + if workload_match: + project_number, pool_id = workload_match.groups() + url = _constants._WORKLOAD_IDENTITY_POOL_TRUST_BOUNDARY_LOOKUP_ENDPOINT.format( + universe_domain=self._universe_domain, + project_number=project_number, + pool_id=pool_id, + ) + else: + # If that fails, try to parse as a workforce pool. + # Audience format: //iam.googleapis.com/locations/global/workforcePools/POOL_ID/providers/PROVIDER_ID + workforce_match = re.search( + r"locations/[^/]+/workforcePools/([^/]+)", self._audience + ) + if workforce_match: + pool_id = workforce_match.groups()[0] + url = _constants._WORKFORCE_POOL_TRUST_BOUNDARY_LOOKUP_ENDPOINT.format( + universe_domain=self._universe_domain, pool_id=pool_id + ) + + if url: + return url + else: + # If both fail, the audience format is invalid. + raise exceptions.InvalidValue("Invalid audience format.") + + def _make_copy(self): + kwargs = self._constructor_args() + new_cred = self.__class__(**kwargs) + new_cred._cred_file_path = self._cred_file_path + new_cred._metrics_options = self._metrics_options + return new_cred + + @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) + def with_quota_project(self, quota_project_id): + # Return copy of instance with the provided quota project ID. + cred = self._make_copy() + cred._quota_project_id = quota_project_id + return cred + + @_helpers.copy_docstring(credentials.CredentialsWithTokenUri) + def with_token_uri(self, token_uri): + cred = self._make_copy() + cred._token_url = token_uri + return cred + + @_helpers.copy_docstring(credentials.CredentialsWithUniverseDomain) + def with_universe_domain(self, universe_domain): + cred = self._make_copy() + cred._universe_domain = universe_domain + return cred + + @_helpers.copy_docstring(credentials.CredentialsWithTrustBoundary) + def with_trust_boundary(self, trust_boundary): + cred = self._make_copy() + cred._trust_boundary = trust_boundary + return cred + + def _should_initialize_impersonated_credentials(self): + return ( + self._service_account_impersonation_url is not None + and self._impersonated_credentials is None + ) + + def _initialize_impersonated_credentials(self): + """Generates an impersonated credentials. + + For more details, see `projects.serviceAccounts.generateAccessToken`_. + + .. _projects.serviceAccounts.generateAccessToken: https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/generateAccessToken + + Returns: + impersonated_credentials.Credential: The impersonated credentials + object. + + Raises: + google.auth.exceptions.RefreshError: If the generateAccessToken + endpoint returned an error. + """ + # Return copy of instance with no service account impersonation. + kwargs = self._constructor_args() + kwargs.update( + service_account_impersonation_url=None, + service_account_impersonation_options={}, + ) + source_credentials = self.__class__(**kwargs) + source_credentials._metrics_options = self._metrics_options + + # Determine target_principal. + target_principal = self.service_account_email + if not target_principal: + raise exceptions.RefreshError( + "Unable to determine target principal from service account impersonation URL." + ) + + scopes = self._scopes if self._scopes is not None else self._default_scopes + # Initialize and return impersonated credentials. + return impersonated_credentials.Credentials( + source_credentials=source_credentials, + target_principal=target_principal, + target_scopes=scopes, + quota_project_id=self._quota_project_id, + iam_endpoint_override=self._service_account_impersonation_url, + lifetime=self._service_account_impersonation_options.get( + "token_lifetime_seconds" + ), + trust_boundary=self._trust_boundary, + ) + + def _create_default_metrics_options(self): + metrics_options = {} + if self._service_account_impersonation_url: + metrics_options["sa-impersonation"] = "true" + else: + metrics_options["sa-impersonation"] = "false" + if self._service_account_impersonation_options.get("token_lifetime_seconds"): + metrics_options["config-lifetime"] = "true" + else: + metrics_options["config-lifetime"] = "false" + + return metrics_options + + def _mtls_required(self): + """Returns a boolean representing whether the current credential is configured + for mTLS and should add a certificate to the outgoing calls to the sts and service + account impersonation endpoint. + + Returns: + bool: True if the credential is configured for mTLS, False if it is not. + """ + return False + + def _get_mtls_cert_and_key_paths(self): + """Gets the file locations for a certificate and private key file + to be used for configuring mTLS for the sts and service account + impersonation calls. Currently only expected to return a value when using + X509 workload identity federation. + + Returns: + Tuple[str, str]: The cert and key file locations as strings in a tuple. + + Raises: + NotImplementedError: When the current credential is not configured for + mTLS. + """ + raise NotImplementedError( + "_get_mtls_cert_and_key_location must be implemented." + ) + + @classmethod + def from_info(cls, info, **kwargs): + """Creates a Credentials instance from parsed external account info. + + **IMPORTANT**: + This method does not validate the credential configuration. A security + risk occurs when a credential configuration configured with malicious urls + is used. + When the credential configuration is accepted from an + untrusted source, you should validate it before using with this method. + Refer https://cloud.google.com/docs/authentication/external/externally-sourced-credentials for more details. + + Args: + info (Mapping[str, str]): The external account info in Google + format. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.identity_pool.Credentials: The constructed + credentials. + + Raises: + InvalidValue: For invalid parameters. + """ + return cls( + audience=info.get("audience"), + subject_token_type=info.get("subject_token_type"), + token_url=info.get("token_url"), + token_info_url=info.get("token_info_url"), + service_account_impersonation_url=info.get( + "service_account_impersonation_url" + ), + service_account_impersonation_options=info.get( + "service_account_impersonation" + ) + or {}, + client_id=info.get("client_id"), + client_secret=info.get("client_secret"), + credential_source=info.get("credential_source"), + quota_project_id=info.get("quota_project_id"), + workforce_pool_user_project=info.get("workforce_pool_user_project"), + universe_domain=info.get( + "universe_domain", credentials.DEFAULT_UNIVERSE_DOMAIN + ), + trust_boundary=info.get("trust_boundary"), + **kwargs + ) + + @classmethod + def from_file(cls, filename, **kwargs): + """Creates a Credentials instance from an external account json file. + + **IMPORTANT**: + This method does not validate the credential configuration. A security + risk occurs when a credential configuration configured with malicious urls + is used. + When the credential configuration is accepted from an + untrusted source, you should validate it before using with this method. + Refer https://cloud.google.com/docs/authentication/external/externally-sourced-credentials for more details. + + Args: + filename (str): The path to the external account json file. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.identity_pool.Credentials: The constructed + credentials. + """ + with io.open(filename, "r", encoding="utf-8") as json_file: + data = json.load(json_file) + return cls.from_info(data, **kwargs) diff --git a/packages/google-auth/google/auth/external_account_authorized_user.py b/packages/google-auth/google/auth/external_account_authorized_user.py new file mode 100644 index 000000000000..6ca89628f11c --- /dev/null +++ b/packages/google-auth/google/auth/external_account_authorized_user.py @@ -0,0 +1,458 @@ +# 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. + +"""External Account Authorized User Credentials. +This module provides credentials based on OAuth 2.0 access and refresh tokens. +These credentials usually access resources on behalf of a user (resource +owner). + +Specifically, these are sourced using external identities via Workforce Identity Federation. + +Obtaining the initial access and refresh token can be done through the Google Cloud CLI. + +Example credential: +{ + "type": "external_account_authorized_user", + "audience": "//iam.googleapis.com/locations/global/workforcePools/$WORKFORCE_POOL_ID/providers/$PROVIDER_ID", + "refresh_token": "refreshToken", + "token_url": "https://sts.googleapis.com/v1/oauth/token", + "token_info_url": "https://sts.googleapis.com/v1/instrospect", + "client_id": "clientId", + "client_secret": "clientSecret" +} +""" + +import datetime +import io +import json +import re + +from google.auth import _constants +from google.auth import _helpers +from google.auth import credentials +from google.auth import exceptions +from google.oauth2 import sts +from google.oauth2 import utils + +_EXTERNAL_ACCOUNT_AUTHORIZED_USER_JSON_TYPE = "external_account_authorized_user" + + +class Credentials( + credentials.CredentialsWithQuotaProject, + credentials.ReadOnlyScoped, + credentials.CredentialsWithTokenUri, + credentials.CredentialsWithTrustBoundary, +): + """Credentials for External Account Authorized Users. + + This is used to instantiate Credentials for exchanging refresh tokens from + authorized users for Google access token and authorizing requests to Google + APIs. + + The credentials are considered immutable. If you want to modify the + quota project, use `with_quota_project` and if you want to modify the token + uri, use `with_token_uri`. + + **IMPORTANT**: + This class does not validate the credential configuration. A security + risk occurs when a credential configuration configured with malicious urls + is used. + When the credential configuration is accepted from an + untrusted source, you should validate it before using. + Refer https://cloud.google.com/docs/authentication/external/externally-sourced-credentials for more details. + """ + + def __init__( + self, + token=None, + expiry=None, + refresh_token=None, + audience=None, + client_id=None, + client_secret=None, + token_url=None, + token_info_url=None, + revoke_url=None, + scopes=None, + quota_project_id=None, + universe_domain=credentials.DEFAULT_UNIVERSE_DOMAIN, + trust_boundary=None, + ): + """Instantiates a external account authorized user credentials object. + + Args: + token (str): The OAuth 2.0 access token. Can be None if refresh information + is provided. + expiry (datetime.datetime): The optional expiration datetime of the OAuth 2.0 access + token. + refresh_token (str): The optional OAuth 2.0 refresh token. If specified, + credentials can be refreshed. + audience (str): The optional STS audience which contains the resource name for the workforce + pool and the provider identifier in that pool. + client_id (str): The OAuth 2.0 client ID. Must be specified for refresh, can be left as + None if the token can not be refreshed. + client_secret (str): The OAuth 2.0 client secret. Must be specified for refresh, can be + left as None if the token can not be refreshed. + token_url (str): The optional STS token exchange endpoint for refresh. Must be specified for + refresh, can be left as None if the token can not be refreshed. + token_info_url (str): The optional STS endpoint URL for token introspection. + revoke_url (str): The optional STS endpoint URL for revoking tokens. + quota_project_id (str): The optional project ID used for quota and billing. + This project may be different from the project used to + create the credentials. + universe_domain (Optional[str]): The universe domain. The default value + is googleapis.com. + trust_boundary (Mapping[str,str]): A credential trust boundary. + + Returns: + google.auth.external_account_authorized_user.Credentials: The + constructed credentials. + """ + super(Credentials, self).__init__() + + self.token = token + self.expiry = expiry + self._audience = audience + self._refresh_token_val = refresh_token + self._token_url = token_url + self._token_info_url = token_info_url + self._client_id = client_id + self._client_secret = client_secret + self._revoke_url = revoke_url + self._quota_project_id = quota_project_id + self._scopes = scopes + self._universe_domain = universe_domain or credentials.DEFAULT_UNIVERSE_DOMAIN + self._cred_file_path = None + self._trust_boundary = trust_boundary + + if not self.valid and not self.can_refresh: + raise exceptions.InvalidOperation( + "Token should be created with fields to make it valid (`token` and " + "`expiry`), or fields to allow it to refresh (`refresh_token`, " + "`token_url`, `client_id`, `client_secret`)." + ) + + self._client_auth = None + if self._client_id: + self._client_auth = utils.ClientAuthentication( + utils.ClientAuthType.basic, self._client_id, self._client_secret + ) + self._sts_client = sts.Client(self._token_url, self._client_auth) + + @property + def info(self): + """Generates the serializable dictionary representation of the current + credentials. + + Returns: + Mapping: The dictionary representation of the credentials. This is the + reverse of the "from_info" method defined in this class. It is + useful for serializing the current credentials so it can deserialized + later. + """ + config_info = self.constructor_args() + config_info.update(type=_EXTERNAL_ACCOUNT_AUTHORIZED_USER_JSON_TYPE) + if config_info["expiry"]: + config_info["expiry"] = config_info["expiry"].isoformat() + "Z" + + return {key: value for key, value in config_info.items() if value is not None} + + def constructor_args(self): + return { + "audience": self._audience, + "refresh_token": self._refresh_token_val, + "token_url": self._token_url, + "token_info_url": self._token_info_url, + "client_id": self._client_id, + "client_secret": self._client_secret, + "token": self.token, + "expiry": self.expiry, + "revoke_url": self._revoke_url, + "scopes": self._scopes, + "quota_project_id": self._quota_project_id, + "universe_domain": self._universe_domain, + "trust_boundary": self._trust_boundary, + } + + @property + def scopes(self): + """Optional[str]: The OAuth 2.0 permission scopes.""" + return self._scopes + + @property + def requires_scopes(self): + """False: OAuth 2.0 credentials have their scopes set when + the initial token is requested and can not be changed.""" + return False + + @property + def client_id(self): + """Optional[str]: The OAuth 2.0 client ID.""" + return self._client_id + + @property + def client_secret(self): + """Optional[str]: The OAuth 2.0 client secret.""" + return self._client_secret + + @property + def audience(self): + """Optional[str]: The STS audience which contains the resource name for the + workforce pool and the provider identifier in that pool.""" + return self._audience + + @property + def refresh_token(self): + """Optional[str]: The OAuth 2.0 refresh token.""" + return self._refresh_token_val + + @property + def token_url(self): + """Optional[str]: The STS token exchange endpoint for refresh.""" + return self._token_url + + @property + def token_info_url(self): + """Optional[str]: The STS endpoint for token info.""" + return self._token_info_url + + @property + def revoke_url(self): + """Optional[str]: The STS endpoint for token revocation.""" + return self._revoke_url + + @property + def is_user(self): + """True: This credential always represents a user.""" + return True + + @property + def can_refresh(self): + return all( + ( + self._refresh_token_val, + self._token_url, + self._client_id, + self._client_secret, + ) + ) + + def get_project_id(self, request=None): + """Retrieves the project ID corresponding to the workload identity or workforce pool. + For workforce pool credentials, it returns the project ID corresponding to + the workforce_pool_user_project. + + When not determinable, None is returned. + + Args: + request (google.auth.transport.requests.Request): Request object. + Unused here, but passed from _default.default(). + + Return: + str: project ID is not determinable for this credential type so it returns None + """ + + return None + + def to_json(self, strip=None): + """Utility function that creates a JSON representation of this + credential. + Args: + strip (Sequence[str]): Optional list of members to exclude from the + generated JSON. + Returns: + str: A JSON representation of this instance. When converted into + a dictionary, it can be passed to from_info() + to create a new instance. + """ + strip = strip if strip else [] + return json.dumps({k: v for (k, v) in self.info.items() if k not in strip}) + + def _refresh_token(self, request): + """Refreshes the access token. + + Args: + request (google.auth.transport.Request): The object used to make + HTTP requests. + + Raises: + google.auth.exceptions.RefreshError: If the credentials could + not be refreshed. + """ + if not self.can_refresh: + raise exceptions.RefreshError( + "The credentials do not contain the necessary fields need to " + "refresh the access token. You must specify refresh_token, " + "token_url, client_id, and client_secret." + ) + + now = _helpers.utcnow() + response_data = self._sts_client.refresh_token(request, self._refresh_token_val) + + self.token = response_data.get("access_token") + + lifetime = datetime.timedelta(seconds=response_data.get("expires_in")) + self.expiry = now + lifetime + + if "refresh_token" in response_data: + self._refresh_token_val = response_data["refresh_token"] + + def _build_trust_boundary_lookup_url(self): + """Builds and returns the URL for the trust boundary lookup API.""" + # Audience format: //iam.googleapis.com/locations/global/workforcePools/POOL_ID/providers/PROVIDER_ID + match = re.search(r"locations/[^/]+/workforcePools/([^/]+)", self._audience) + + if not match: + raise exceptions.InvalidValue("Invalid workforce pool audience format.") + + pool_id = match.groups()[0] + + return _constants._WORKFORCE_POOL_TRUST_BOUNDARY_LOOKUP_ENDPOINT.format( + universe_domain=self._universe_domain, pool_id=pool_id + ) + + def revoke(self, request): + """Revokes the refresh token. + + Args: + request (google.auth.transport.Request): The object used to make + HTTP requests. + + Raises: + google.auth.exceptions.OAuthError: If the token could not be + revoked. + """ + if not self._revoke_url or not self._refresh_token_val: + raise exceptions.OAuthError( + "The credentials do not contain the necessary fields to " + "revoke the refresh token. You must specify revoke_url and " + "refresh_token." + ) + + self._sts_client.revoke_token( + request, self._refresh_token_val, "refresh_token", self._revoke_url + ) + self.token = None + self._refresh_token = None + + @_helpers.copy_docstring(credentials.Credentials) + def get_cred_info(self): + if self._cred_file_path: + return { + "credential_source": self._cred_file_path, + "credential_type": "external account authorized user credentials", + } + return None + + def _make_copy(self): + kwargs = self.constructor_args() + cred = self.__class__(**kwargs) + cred._cred_file_path = self._cred_file_path + return cred + + @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) + def with_quota_project(self, quota_project_id): + cred = self._make_copy() + cred._quota_project_id = quota_project_id + return cred + + @_helpers.copy_docstring(credentials.CredentialsWithTokenUri) + def with_token_uri(self, token_uri): + cred = self._make_copy() + cred._token_url = token_uri + return cred + + @_helpers.copy_docstring(credentials.CredentialsWithUniverseDomain) + def with_universe_domain(self, universe_domain): + cred = self._make_copy() + cred._universe_domain = universe_domain + return cred + + @_helpers.copy_docstring(credentials.CredentialsWithTrustBoundary) + def with_trust_boundary(self, trust_boundary): + cred = self._make_copy() + cred._trust_boundary = trust_boundary + return cred + + @classmethod + def from_info(cls, info, **kwargs): + """Creates a Credentials instance from parsed external account info. + + **IMPORTANT**: + This method does not validate the credential configuration. A security + risk occurs when a credential configuration configured with malicious urls + is used. + When the credential configuration is accepted from an + untrusted source, you should validate it before using with this method. + Refer https://cloud.google.com/docs/authentication/external/externally-sourced-credentials for more details. + + Args: + info (Mapping[str, str]): The external account info in Google + format. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.external_account_authorized_user.Credentials: The + constructed credentials. + + Raises: + ValueError: For invalid parameters. + """ + expiry = info.get("expiry") + if expiry: + expiry = datetime.datetime.strptime( + expiry.rstrip("Z").split(".")[0], "%Y-%m-%dT%H:%M:%S" + ) + return cls( + audience=info.get("audience"), + refresh_token=info.get("refresh_token"), + token_url=info.get("token_url"), + token_info_url=info.get("token_info_url"), + client_id=info.get("client_id"), + client_secret=info.get("client_secret"), + token=info.get("token"), + expiry=expiry, + revoke_url=info.get("revoke_url"), + quota_project_id=info.get("quota_project_id"), + scopes=info.get("scopes"), + universe_domain=info.get( + "universe_domain", credentials.DEFAULT_UNIVERSE_DOMAIN + ), + trust_boundary=info.get("trust_boundary"), + **kwargs + ) + + @classmethod + def from_file(cls, filename, **kwargs): + """Creates a Credentials instance from an external account json file. + + **IMPORTANT**: + This method does not validate the credential configuration. A security + risk occurs when a credential configuration configured with malicious urls + is used. + When the credential configuration is accepted from an + untrusted source, you should validate it before using with this method. + Refer https://cloud.google.com/docs/authentication/external/externally-sourced-credentials for more details. + + Args: + filename (str): The path to the external account json file. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.external_account_authorized_user.Credentials: The + constructed credentials. + """ + with io.open(filename, "r", encoding="utf-8") as json_file: + data = json.load(json_file) + return cls.from_info(data, **kwargs) diff --git a/packages/google-auth/google/auth/iam.py b/packages/google-auth/google/auth/iam.py new file mode 100644 index 000000000000..1e4cdffec188 --- /dev/null +++ b/packages/google-auth/google/auth/iam.py @@ -0,0 +1,136 @@ +# Copyright 2017 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. + +"""Tools for using the Google `Cloud Identity and Access Management (IAM) +API`_'s auth-related functionality. + +.. _Cloud Identity and Access Management (IAM) API: + https://cloud.google.com/iam/docs/ +""" + +import base64 +import http.client as http_client +import json + +from google.auth import _exponential_backoff +from google.auth import _helpers +from google.auth import credentials +from google.auth import crypt +from google.auth import exceptions + +IAM_RETRY_CODES = { + http_client.INTERNAL_SERVER_ERROR, + http_client.BAD_GATEWAY, + http_client.SERVICE_UNAVAILABLE, + http_client.GATEWAY_TIMEOUT, +} + +_IAM_SCOPE = ["https://www.googleapis.com/auth/iam"] + +_IAM_ENDPOINT = ( + "https://iamcredentials.googleapis.com/v1/projects/-" + + "/serviceAccounts/{}:generateAccessToken" +) + +_IAM_SIGN_ENDPOINT = ( + "https://iamcredentials.googleapis.com/v1/projects/-" + + "/serviceAccounts/{}:signBlob" +) + +_IAM_SIGNJWT_ENDPOINT = ( + "https://iamcredentials.googleapis.com/v1/projects/-" + + "/serviceAccounts/{}:signJwt" +) + +_IAM_IDTOKEN_ENDPOINT = ( + "https://iamcredentials.googleapis.com/v1/" + + "projects/-/serviceAccounts/{}:generateIdToken" +) + + +class Signer(crypt.Signer): + """Signs messages using the IAM `signBlob API`_. + + This is useful when you need to sign bytes but do not have access to the + credential's private key file. + + .. _signBlob API: + https://cloud.google.com/iam/reference/rest/v1/projects.serviceAccounts + /signBlob + """ + + def __init__(self, request, credentials, service_account_email): + """ + Args: + request (google.auth.transport.Request): The object used to make + HTTP requests. + credentials (google.auth.credentials.Credentials): The credentials + that will be used to authenticate the request to the IAM API. + The credentials must have of one the following scopes: + + - https://www.googleapis.com/auth/iam + - https://www.googleapis.com/auth/cloud-platform + service_account_email (str): The service account email identifying + which service account to use to sign bytes. Often, this can + be the same as the service account email in the given + credentials. + """ + self._request = request + self._credentials = credentials + self._service_account_email = service_account_email + + def _make_signing_request(self, message): + """Makes a request to the API signBlob API.""" + message = _helpers.to_bytes(message) + + method = "POST" + url = _IAM_SIGN_ENDPOINT.replace( + credentials.DEFAULT_UNIVERSE_DOMAIN, self._credentials.universe_domain + ).format(self._service_account_email) + headers = {"Content-Type": "application/json"} + body = json.dumps( + {"payload": base64.b64encode(message).decode("utf-8")} + ).encode("utf-8") + + retries = _exponential_backoff.ExponentialBackoff() + for _ in retries: + self._credentials.before_request(self._request, method, url, headers) + + response = self._request(url=url, method=method, body=body, headers=headers) + + if response.status in IAM_RETRY_CODES: + continue + + if response.status != http_client.OK: + raise exceptions.TransportError( + "Error calling the IAM signBlob API: {}".format(response.data) + ) + + return json.loads(response.data.decode("utf-8")) + raise exceptions.TransportError("exhausted signBlob endpoint retries") + + @property + def key_id(self): + """Optional[str]: The key ID used to identify this private key. + + .. warning:: + This is always ``None``. The key ID used by IAM can not + be reliably determined ahead of time. + """ + return None + + @_helpers.copy_docstring(crypt.Signer) + def sign(self, message): + response = self._make_signing_request(message) + return base64.b64decode(response["signedBlob"]) diff --git a/packages/google-auth/google/auth/identity_pool.py b/packages/google-auth/google/auth/identity_pool.py new file mode 100644 index 000000000000..ebcd59e8000f --- /dev/null +++ b/packages/google-auth/google/auth/identity_pool.py @@ -0,0 +1,553 @@ +# 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. + +"""Identity Pool Credentials. + +This module provides credentials to access Google Cloud resources from on-prem +or non-Google Cloud platforms which support external credentials (e.g. OIDC ID +tokens) retrieved from local file locations or local servers. This includes +Microsoft Azure and OIDC identity providers (e.g. K8s workloads registered with +Hub with Hub workload identity enabled). + +These credentials are recommended over the use of service account credentials +in on-prem/non-Google Cloud platforms as they do not involve the management of +long-live service account private keys. + +Identity Pool Credentials are initialized using external_account +arguments which are typically loaded from an external credentials file or +an external credentials URL. + +This module also provides a definition for an abstract subject token supplier. +This supplier can be implemented to return a valid OIDC or SAML2.0 subject token +and used to create Identity Pool credentials. The credentials will then call the +supplier instead of using pre-defined methods such as reading a local file or +calling a URL. +""" + +try: + from collections.abc import Mapping +# Python 2.7 compatibility +except ImportError: # pragma: NO COVER + from collections import Mapping # type: ignore +import abc +import base64 +import json +import os +from typing import NamedTuple + +from google.auth import _helpers +from google.auth import exceptions +from google.auth import external_account +from google.auth.transport import _mtls_helper + + +class SubjectTokenSupplier(metaclass=abc.ABCMeta): + """Base class for subject token suppliers. This can be implemented with custom logic to retrieve + a subject token to exchange for a Google Cloud access token when using Workload or + Workforce Identity Federation. The identity pool credential does not cache the subject token, + so caching logic should be added in the implementation. + """ + + @abc.abstractmethod + def get_subject_token(self, context, request): + """Returns the requested subject token. The subject token must be valid. + + .. warning: This is not cached by the calling Google credential, so caching logic should be implemented in the supplier. + + Args: + context (google.auth.externalaccount.SupplierContext): The context object + containing information about the requested audience and subject token type. + request (google.auth.transport.Request): The object used to make + HTTP requests. + + Raises: + google.auth.exceptions.RefreshError: If an error is encountered during + subject token retrieval logic. + + Returns: + str: The requested subject token string. + """ + raise NotImplementedError("") + + +class _TokenContent(NamedTuple): + """Models the token content response from file and url internal suppliers. + Attributes: + content (str): The string content of the file or URL response. + location (str): The location the content was retrieved from. This will either be a file location or a URL. + """ + + content: str + location: str + + +class _FileSupplier(SubjectTokenSupplier): + """Internal implementation of subject token supplier which supports reading a subject token from a file.""" + + def __init__(self, path, format_type, subject_token_field_name): + self._path = path + self._format_type = format_type + self._subject_token_field_name = subject_token_field_name + + @_helpers.copy_docstring(SubjectTokenSupplier) + def get_subject_token(self, context, request): + if not os.path.exists(self._path): + raise exceptions.RefreshError("File '{}' was not found.".format(self._path)) + + with open(self._path, "r", encoding="utf-8") as file_obj: + token_content = _TokenContent(file_obj.read(), self._path) + + return _parse_token_data( + token_content, self._format_type, self._subject_token_field_name + ) + + +class _UrlSupplier(SubjectTokenSupplier): + """Internal implementation of subject token supplier which supports retrieving a subject token by calling a URL endpoint.""" + + def __init__(self, url, format_type, subject_token_field_name, headers): + self._url = url + self._format_type = format_type + self._subject_token_field_name = subject_token_field_name + self._headers = headers + + @_helpers.copy_docstring(SubjectTokenSupplier) + def get_subject_token(self, context, request): + response = request(url=self._url, method="GET", headers=self._headers) + + # support both string and bytes type response.data + response_body = ( + response.data.decode("utf-8") + if hasattr(response.data, "decode") + else response.data + ) + + if response.status != 200: + raise exceptions.RefreshError( + "Unable to retrieve Identity Pool subject token", response_body + ) + token_content = _TokenContent(response_body, self._url) + return _parse_token_data( + token_content, self._format_type, self._subject_token_field_name + ) + + +class _X509Supplier(SubjectTokenSupplier): + """Internal supplier for X509 workload credentials. This class is used internally and always returns an empty string as the subject token.""" + + def __init__(self, trust_chain_path, leaf_cert_callback): + self._trust_chain_path = trust_chain_path + self._leaf_cert_callback = leaf_cert_callback + + @_helpers.copy_docstring(SubjectTokenSupplier) + def get_subject_token(self, context, request): + # Import OpennSSL inline because it is an extra import only required by customers + # using mTLS. + from OpenSSL import crypto + + leaf_cert = crypto.load_certificate( + crypto.FILETYPE_PEM, self._leaf_cert_callback() + ) + trust_chain = self._read_trust_chain() + cert_chain = [] + + cert_chain.append(_X509Supplier._encode_cert(leaf_cert)) + + if trust_chain is None or len(trust_chain) == 0: + return json.dumps(cert_chain) + + # Append the first cert if it is not the leaf cert. + first_cert = _X509Supplier._encode_cert(trust_chain[0]) + if first_cert != cert_chain[0]: + cert_chain.append(first_cert) + + for i in range(1, len(trust_chain)): + encoded = _X509Supplier._encode_cert(trust_chain[i]) + # Check if the current cert is the leaf cert and raise an exception if it is. + if encoded == cert_chain[0]: + raise exceptions.RefreshError( + "The leaf certificate must be at the top of the trust chain file" + ) + else: + cert_chain.append(encoded) + return json.dumps(cert_chain) + + def _read_trust_chain(self): + # Import OpennSSL inline because it is an extra import only required by customers + # using mTLS. + from OpenSSL import crypto + + certificate_trust_chain = [] + # If no trust chain path was provided, return an empty list. + if self._trust_chain_path is None or self._trust_chain_path == "": + return certificate_trust_chain + try: + # Open the trust chain file. + with open(self._trust_chain_path, "rb") as f: + trust_chain_data = f.read() + # Split PEM data into individual certificates. + cert_blocks = trust_chain_data.split(b"-----BEGIN CERTIFICATE-----") + for cert_block in cert_blocks: + # Skip empty blocks. + if cert_block.strip(): + cert_data = b"-----BEGIN CERTIFICATE-----" + cert_block + try: + # Load each certificate and add it to the trust chain. + cert = crypto.load_certificate( + crypto.FILETYPE_PEM, cert_data + ) + certificate_trust_chain.append(cert) + except Exception as e: + raise exceptions.RefreshError( + "Error loading PEM certificates from the trust chain file '{}'".format( + self._trust_chain_path + ) + ) from e + return certificate_trust_chain + except FileNotFoundError: + raise exceptions.RefreshError( + "Trust chain file '{}' was not found.".format(self._trust_chain_path) + ) + + def _encode_cert(cert): + # Import OpennSSL inline because it is an extra import only required by customers + # using mTLS. + from OpenSSL import crypto + + return base64.b64encode( + crypto.dump_certificate(crypto.FILETYPE_ASN1, cert) + ).decode("utf-8") + + +def _parse_token_data(token_content, format_type="text", subject_token_field_name=None): + if format_type == "text": + token = token_content.content + else: + try: + # Parse file content as JSON. + response_data = json.loads(token_content.content) + # Get the subject_token. + token = response_data[subject_token_field_name] + except (KeyError, ValueError): + raise exceptions.RefreshError( + "Unable to parse subject_token from JSON file '{}' using key '{}'".format( + token_content.location, subject_token_field_name + ) + ) + if not token: + raise exceptions.RefreshError( + "Missing subject_token in the credential_source file" + ) + return token + + +class Credentials(external_account.Credentials): + """External account credentials sourced from files and URLs. + + **IMPORTANT**: + This class does not validate the credential configuration. A security + risk occurs when a credential configuration configured with malicious urls + is used. + When the credential configuration is accepted from an + untrusted source, you should validate it before using. + Refer https://cloud.google.com/docs/authentication/external/externally-sourced-credentials for more details. + """ + + def __init__( + self, + audience, + subject_token_type, + token_url=external_account._DEFAULT_TOKEN_URL, + credential_source=None, + subject_token_supplier=None, + *args, + **kwargs + ): + """Instantiates an external account credentials object from a file/URL. + + Args: + audience (str): The STS audience field. + subject_token_type (str): The subject token type based on the Oauth2.0 token exchange spec. + Expected values include:: + + “urn:ietf:params:oauth:token-type:jwt” + “urn:ietf:params:oauth:token-type:id-token” + “urn:ietf:params:oauth:token-type:saml2” + + token_url (Optional [str]): The STS endpoint URL. If not provided, will default to "https://sts.googleapis.com/v1/token". + credential_source (Optional [Mapping]): The credential source dictionary used to + provide instructions on how to retrieve external credential to be + exchanged for Google access tokens. Either a credential source or + a subject token supplier must be provided. + + Example credential_source for url-sourced credential:: + + { + "url": "http://www.example.com", + "format": { + "type": "json", + "subject_token_field_name": "access_token", + }, + "headers": {"foo": "bar"}, + } + + Example credential_source for file-sourced credential:: + + { + "file": "/path/to/token/file.txt" + } + subject_token_supplier (Optional [SubjectTokenSupplier]): Optional subject token supplier. + This will be called to supply a valid subject token which will then + be exchanged for Google access tokens. Either a subject token supplier + or a credential source must be provided. + args (List): Optional positional arguments passed into the underlying :meth:`~external_account.Credentials.__init__` method. + kwargs (Mapping): Optional keyword arguments passed into the underlying :meth:`~external_account.Credentials.__init__` method. + + Raises: + google.auth.exceptions.RefreshError: If an error is encountered during + access token retrieval logic. + ValueError: For invalid parameters. + + .. note:: Typically one of the helper constructors + :meth:`from_file` or + :meth:`from_info` are used instead of calling the constructor directly. + """ + + super(Credentials, self).__init__( + audience=audience, + subject_token_type=subject_token_type, + token_url=token_url, + credential_source=credential_source, + *args, + **kwargs + ) + if credential_source is None and subject_token_supplier is None: + raise exceptions.InvalidValue( + "A valid credential source or a subject token supplier must be provided." + ) + if credential_source is not None and subject_token_supplier is not None: + raise exceptions.InvalidValue( + "Identity pool credential cannot have both a credential source and a subject token supplier." + ) + + if subject_token_supplier is not None: + self._subject_token_supplier = subject_token_supplier + self._credential_source_file = None + self._credential_source_url = None + self._credential_source_certificate = None + else: + if not isinstance(credential_source, Mapping): + self._credential_source_executable = None + raise exceptions.MalformedError( + "Invalid credential_source. The credential_source is not a dict." + ) + self._credential_source_file = credential_source.get("file") + self._credential_source_url = credential_source.get("url") + self._credential_source_certificate = credential_source.get("certificate") + + # environment_id is only supported in AWS or dedicated future external + # account credentials. + if "environment_id" in credential_source: + raise exceptions.MalformedError( + "Invalid Identity Pool credential_source field 'environment_id'" + ) + + # check that only one of file, url, or certificate are provided. + self._validate_single_source() + + if self._credential_source_certificate: + self._validate_certificate_config() + else: + self._validate_file_or_url_config(credential_source) + + if self._credential_source_file: + self._subject_token_supplier = _FileSupplier( + self._credential_source_file, + self._credential_source_format_type, + self._credential_source_field_name, + ) + elif self._credential_source_url: + self._subject_token_supplier = _UrlSupplier( + self._credential_source_url, + self._credential_source_format_type, + self._credential_source_field_name, + self._credential_source_headers, + ) + else: # self._credential_source_certificate + self._subject_token_supplier = _X509Supplier( + self._trust_chain_path, self._get_cert_bytes + ) + + @_helpers.copy_docstring(external_account.Credentials) + def retrieve_subject_token(self, request): + return self._subject_token_supplier.get_subject_token( + self._supplier_context, request + ) + + def _get_mtls_cert_and_key_paths(self): + if self._credential_source_certificate is None: + raise exceptions.RefreshError( + 'The credential is not configured to use mtls requests. The credential should include a "certificate" section in the credential source.' + ) + else: + return _mtls_helper._get_workload_cert_and_key_paths( + self._certificate_config_location + ) + + def _get_cert_bytes(self): + cert_path, _ = self._get_mtls_cert_and_key_paths() + return _mtls_helper._read_cert_file(cert_path) + + def _mtls_required(self): + return self._credential_source_certificate is not None + + def _create_default_metrics_options(self): + metrics_options = super(Credentials, self)._create_default_metrics_options() + # Check that credential source is a dict before checking for credential type. This check needs to be done + # here because the external_account credential constructor needs to pass the metrics options to the + # impersonated credential object before the identity_pool credentials are validated. + if isinstance(self._credential_source, Mapping): + if self._credential_source.get("file"): + metrics_options["source"] = "file" + elif self._credential_source.get("url"): + metrics_options["source"] = "url" + else: + metrics_options["source"] = "x509" + else: + metrics_options["source"] = "programmatic" + return metrics_options + + def _has_custom_supplier(self): + return self._credential_source is None + + def _constructor_args(self): + args = super(Credentials, self)._constructor_args() + # If a custom supplier was used, append it to the args dict. + if self._has_custom_supplier(): + args.update({"subject_token_supplier": self._subject_token_supplier}) + return args + + def _validate_certificate_config(self): + self._certificate_config_location = self._credential_source_certificate.get( + "certificate_config_location" + ) + use_default = self._credential_source_certificate.get( + "use_default_certificate_config" + ) + self._trust_chain_path = self._credential_source_certificate.get( + "trust_chain_path" + ) + if self._certificate_config_location and use_default: + raise exceptions.MalformedError( + "Invalid certificate configuration, certificate_config_location cannot be specified when use_default_certificate_config = true." + ) + if not self._certificate_config_location and not use_default: + raise exceptions.MalformedError( + "Invalid certificate configuration, use_default_certificate_config should be true if no certificate_config_location is provided." + ) + + def _validate_file_or_url_config(self, credential_source): + self._credential_source_headers = credential_source.get("headers") + credential_source_format = credential_source.get("format", {}) + # Get credential_source format type. When not provided, this + # defaults to text. + self._credential_source_format_type = ( + credential_source_format.get("type") or "text" + ) + if self._credential_source_format_type not in ["text", "json"]: + raise exceptions.MalformedError( + "Invalid credential_source format '{}'".format( + self._credential_source_format_type + ) + ) + # For JSON types, get the required subject_token field name. + if self._credential_source_format_type == "json": + self._credential_source_field_name = credential_source_format.get( + "subject_token_field_name" + ) + if self._credential_source_field_name is None: + raise exceptions.MalformedError( + "Missing subject_token_field_name for JSON credential_source format" + ) + else: + self._credential_source_field_name = None + + def _validate_single_source(self): + credential_sources = [ + self._credential_source_file, + self._credential_source_url, + self._credential_source_certificate, + ] + valid_credential_sources = list( + filter(lambda source: source is not None, credential_sources) + ) + + if len(valid_credential_sources) > 1: + raise exceptions.MalformedError( + "Ambiguous credential_source. 'file', 'url', and 'certificate' are mutually exclusive.." + ) + if len(valid_credential_sources) != 1: + raise exceptions.MalformedError( + "Missing credential_source. A 'file', 'url', or 'certificate' must be provided." + ) + + @classmethod + def from_info(cls, info, **kwargs): + """Creates an Identity Pool Credentials instance from parsed external account info. + + **IMPORTANT**: + This method does not validate the credential configuration. A security + risk occurs when a credential configuration configured with malicious urls + is used. + When the credential configuration is accepted from an + untrusted source, you should validate it before using with this method. + Refer https://cloud.google.com/docs/authentication/external/externally-sourced-credentials for more details. + + Args: + info (Mapping[str, str]): The Identity Pool external account info in Google + format. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.identity_pool.Credentials: The constructed + credentials. + + Raises: + ValueError: For invalid parameters. + """ + subject_token_supplier = info.get("subject_token_supplier") + kwargs.update({"subject_token_supplier": subject_token_supplier}) + return super(Credentials, cls).from_info(info, **kwargs) + + @classmethod + def from_file(cls, filename, **kwargs): + """Creates an IdentityPool Credentials instance from an external account json file. + + **IMPORTANT**: + This method does not validate the credential configuration. A security + risk occurs when a credential configuration configured with malicious urls + is used. + When the credential configuration is accepted from an + untrusted source, you should validate it before using with this method. + Refer https://cloud.google.com/docs/authentication/external/externally-sourced-credentials for more details. + + Args: + filename (str): The path to the IdentityPool external account json file. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.identity_pool.Credentials: The constructed + credentials. + """ + return super(Credentials, cls).from_file(filename, **kwargs) diff --git a/packages/google-auth/google/auth/impersonated_credentials.py b/packages/google-auth/google/auth/impersonated_credentials.py new file mode 100644 index 000000000000..334573428e96 --- /dev/null +++ b/packages/google-auth/google/auth/impersonated_credentials.py @@ -0,0 +1,705 @@ +# Copyright 2018 Google Inc. +# +# 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. + +"""Google Cloud Impersonated credentials. + +This module provides authentication for applications where local credentials +impersonates a remote service account using `IAM Credentials API`_. + +This class can be used to impersonate a service account as long as the original +Credential object has the "Service Account Token Creator" role on the target +service account. + + .. _IAM Credentials API: + https://cloud.google.com/iam/credentials/reference/rest/ +""" + +import base64 +import copy +from datetime import datetime +import http.client as http_client +import json + +from google.auth import _exponential_backoff +from google.auth import _helpers +from google.auth import credentials +from google.auth import exceptions +from google.auth import iam +from google.auth import jwt +from google.auth import metrics +from google.oauth2 import _client + + +_REFRESH_ERROR = "Unable to acquire impersonated credentials" + +_DEFAULT_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds + +_GOOGLE_OAUTH2_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token" +_TRUST_BOUNDARY_LOOKUP_ENDPOINT = ( + "https://iamcredentials.{}/v1/projects/-/serviceAccounts/{}/allowedLocations" +) + +_SOURCE_CREDENTIAL_AUTHORIZED_USER_TYPE = "authorized_user" +_SOURCE_CREDENTIAL_SERVICE_ACCOUNT_TYPE = "service_account" +_SOURCE_CREDENTIAL_EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE = ( + "external_account_authorized_user" +) + + +def _make_iam_token_request( + request, + principal, + headers, + body, + universe_domain=credentials.DEFAULT_UNIVERSE_DOMAIN, + iam_endpoint_override=None, +): + """Makes a request to the Google Cloud IAM service for an access token. + Args: + request (Request): The Request object to use. + principal (str): The principal to request an access token for. + headers (Mapping[str, str]): Map of headers to transmit. + body (Mapping[str, str]): JSON Payload body for the iamcredentials + API call. + iam_endpoint_override (Optiona[str]): The full IAM endpoint override + with the target_principal embedded. This is useful when supporting + impersonation with regional endpoints. + + Raises: + google.auth.exceptions.TransportError: Raised if there is an underlying + HTTP connection error + google.auth.exceptions.RefreshError: Raised if the impersonated + credentials are not available. Common reasons are + `iamcredentials.googleapis.com` is not enabled or the + `Service Account Token Creator` is not assigned + """ + iam_endpoint = iam_endpoint_override or iam._IAM_ENDPOINT.replace( + credentials.DEFAULT_UNIVERSE_DOMAIN, universe_domain + ).format(principal) + + body = json.dumps(body).encode("utf-8") + + response = request(url=iam_endpoint, method="POST", headers=headers, body=body) + + # support both string and bytes type response.data + response_body = ( + response.data.decode("utf-8") + if hasattr(response.data, "decode") + else response.data + ) + + if response.status != http_client.OK: + raise exceptions.RefreshError(_REFRESH_ERROR, response_body) + + try: + token_response = json.loads(response_body) + token = token_response["accessToken"] + expiry = datetime.strptime(token_response["expireTime"], "%Y-%m-%dT%H:%M:%SZ") + + return token, expiry + + except (KeyError, ValueError) as caught_exc: + new_exc = exceptions.RefreshError( + "{}: No access token or invalid expiration in response.".format( + _REFRESH_ERROR + ), + response_body, + ) + raise new_exc from caught_exc + + +class Credentials( + credentials.Scoped, + credentials.CredentialsWithQuotaProject, + credentials.Signing, + credentials.CredentialsWithTrustBoundary, +): + """This module defines impersonated credentials which are essentially + impersonated identities. + + Impersonated Credentials allows credentials issued to a user or + service account to impersonate another. The target service account must + grant the originating credential principal the + `Service Account Token Creator`_ IAM role: + + For more information about Token Creator IAM role and + IAMCredentials API, see + `Creating Short-Lived Service Account Credentials`_. + + .. _Service Account Token Creator: + https://cloud.google.com/iam/docs/service-accounts#the_service_account_token_creator_role + + .. _Creating Short-Lived Service Account Credentials: + https://cloud.google.com/iam/docs/creating-short-lived-service-account-credentials + + Usage: + + First grant source_credentials the `Service Account Token Creator` + role on the target account to impersonate. In this example, the + service account represented by svc_account.json has the + token creator role on + `impersonated-account@_project_.iam.gserviceaccount.com`. + + Enable the IAMCredentials API on the source project: + `gcloud services enable iamcredentials.googleapis.com`. + + Initialize a source credential which does not have access to + list bucket:: + + from google.oauth2 import service_account + + target_scopes = [ + 'https://www.googleapis.com/auth/devstorage.read_only'] + + source_credentials = ( + service_account.Credentials.from_service_account_file( + '/path/to/svc_account.json', + scopes=target_scopes)) + + Now use the source credentials to acquire credentials to impersonate + another service account:: + + from google.auth import impersonated_credentials + + target_credentials = impersonated_credentials.Credentials( + source_credentials=source_credentials, + target_principal='impersonated-account@_project_.iam.gserviceaccount.com', + target_scopes = target_scopes, + lifetime=500) + + Resource access is granted:: + + client = storage.Client(credentials=target_credentials) + buckets = client.list_buckets(project='your_project') + for bucket in buckets: + print(bucket.name) + + **IMPORTANT**: + This class does not validate the credential configuration. A security + risk occurs when a credential configuration configured with malicious urls + is used. + When the credential configuration is accepted from an + untrusted source, you should validate it before using. + Refer https://cloud.google.com/docs/authentication/external/externally-sourced-credentials for more details. + """ + + def __init__( + self, + source_credentials, + target_principal, + target_scopes, + delegates=None, + subject=None, + lifetime=_DEFAULT_TOKEN_LIFETIME_SECS, + quota_project_id=None, + iam_endpoint_override=None, + trust_boundary=None, + ): + """ + Args: + source_credentials (google.auth.Credentials): The source credential + used as to acquire the impersonated credentials. + target_principal (str): The service account to impersonate. + target_scopes (Sequence[str]): Scopes to request during the + authorization grant. + delegates (Sequence[str]): The chained list of delegates required + to grant the final access_token. If set, the sequence of + identities must have "Service Account Token Creator" capability + granted to the prceeding identity. For example, if set to + [serviceAccountB, serviceAccountC], the source_credential + must have the Token Creator role on serviceAccountB. + serviceAccountB must have the Token Creator on + serviceAccountC. + Finally, C must have Token Creator on target_principal. + If left unset, source_credential must have that role on + target_principal. + lifetime (int): Number of seconds the delegated credential should + be valid for (upto 3600). + quota_project_id (Optional[str]): The project ID used for quota and billing. + This project may be different from the project used to + create the credentials. + iam_endpoint_override (Optional[str]): The full IAM endpoint override + with the target_principal embedded. This is useful when supporting + impersonation with regional endpoints. + subject (Optional[str]): sub field of a JWT. This field should only be set + if you wish to impersonate as a user. This feature is useful when + using domain wide delegation. + trust_boundary (Mapping[str,str]): A credential trust boundary. + """ + + super(Credentials, self).__init__() + + self._source_credentials = copy.copy(source_credentials) + # Service account source credentials must have the _IAM_SCOPE + # added to refresh correctly. User credentials cannot have + # their original scopes modified. + if isinstance(self._source_credentials, credentials.Scoped): + self._source_credentials = self._source_credentials.with_scopes( + iam._IAM_SCOPE + ) + # If the source credential is service account and self signed jwt + # is needed, we need to create a jwt credential inside it + if ( + hasattr(self._source_credentials, "_create_self_signed_jwt") + and self._source_credentials._always_use_jwt_access + ): + self._source_credentials._create_self_signed_jwt(None) + + self._universe_domain = source_credentials.universe_domain + self._target_principal = target_principal + self._target_scopes = target_scopes + self._delegates = delegates + self._subject = subject + self._lifetime = lifetime or _DEFAULT_TOKEN_LIFETIME_SECS + self.token = None + self.expiry = _helpers.utcnow() + self._quota_project_id = quota_project_id + self._iam_endpoint_override = iam_endpoint_override + self._cred_file_path = None + self._trust_boundary = trust_boundary + + def _metric_header_for_usage(self): + return metrics.CRED_TYPE_SA_IMPERSONATE + + def _refresh_token(self, request): + """Updates credentials with a new access_token representing + the impersonated account. + + Args: + request (google.auth.transport.requests.Request): Request object + to use for refreshing credentials. + """ + + # Refresh our source credentials if it is not valid. + if ( + self._source_credentials.token_state == credentials.TokenState.STALE + or self._source_credentials.token_state == credentials.TokenState.INVALID + ): + self._source_credentials._refresh_token(request) + + body = { + "delegates": self._delegates, + "scope": self._target_scopes, + "lifetime": str(self._lifetime) + "s", + } + + headers = { + "Content-Type": "application/json", + metrics.API_CLIENT_HEADER: metrics.token_request_access_token_impersonate(), + } + + # Apply the source credentials authentication info. + self._source_credentials.apply(headers) + + # If a subject is specified a domain-wide delegation auth-flow is initiated + # to impersonate as the provided subject (user). + if self._subject: + if self.universe_domain != credentials.DEFAULT_UNIVERSE_DOMAIN: + raise exceptions.GoogleAuthError( + "Domain-wide delegation is not supported in universes other " + + "than googleapis.com" + ) + + now = _helpers.utcnow() + payload = { + "iss": self._target_principal, + "scope": _helpers.scopes_to_string(self._target_scopes or ()), + "sub": self._subject, + "aud": _GOOGLE_OAUTH2_TOKEN_ENDPOINT, + "iat": _helpers.datetime_to_secs(now), + "exp": _helpers.datetime_to_secs(now) + _DEFAULT_TOKEN_LIFETIME_SECS, + } + + assertion = _sign_jwt_request( + request=request, + principal=self._target_principal, + headers=headers, + payload=payload, + delegates=self._delegates, + ) + + self.token, self.expiry, _ = _client.jwt_grant( + request, _GOOGLE_OAUTH2_TOKEN_ENDPOINT, assertion + ) + + return + + self.token, self.expiry = _make_iam_token_request( + request=request, + principal=self._target_principal, + headers=headers, + body=body, + universe_domain=self.universe_domain, + iam_endpoint_override=self._iam_endpoint_override, + ) + + def _build_trust_boundary_lookup_url(self): + """Builds and returns the URL for the trust boundary lookup API. + + This method constructs the specific URL for the IAM Credentials API's + `allowedLocations` endpoint, using the credential's universe domain + and service account email. + + Raises: + ValueError: If `self.service_account_email` is None or an empty + string, as it's required to form the URL. + + Returns: + str: The URL for the trust boundary lookup endpoint. + """ + if not self.service_account_email: + raise ValueError( + "Service account email is required to build the trust boundary lookup URL." + ) + return _TRUST_BOUNDARY_LOOKUP_ENDPOINT.format( + self.universe_domain, self.service_account_email + ) + + def sign_bytes(self, message): + from google.auth.transport.requests import AuthorizedSession + + iam_sign_endpoint = iam._IAM_SIGN_ENDPOINT.replace( + credentials.DEFAULT_UNIVERSE_DOMAIN, self.universe_domain + ).format(self._target_principal) + + body = { + "payload": base64.b64encode(message).decode("utf-8"), + "delegates": self._delegates, + } + + headers = {"Content-Type": "application/json"} + + authed_session = AuthorizedSession(self._source_credentials) + + try: + retries = _exponential_backoff.ExponentialBackoff() + for _ in retries: + response = authed_session.post( + url=iam_sign_endpoint, headers=headers, json=body + ) + if response.status_code in iam.IAM_RETRY_CODES: + continue + if response.status_code != http_client.OK: + raise exceptions.TransportError( + "Error calling sign_bytes: {}".format(response.json()) + ) + + return base64.b64decode(response.json()["signedBlob"]) + finally: + authed_session.close() + raise exceptions.TransportError("exhausted signBlob endpoint retries") + + @property + def signer_email(self): + return self._target_principal + + @property + def service_account_email(self): + return self._target_principal + + @property + def signer(self): + return self + + @property + def requires_scopes(self): + return not self._target_scopes + + @_helpers.copy_docstring(credentials.Credentials) + def get_cred_info(self): + if self._cred_file_path: + return { + "credential_source": self._cred_file_path, + "credential_type": "impersonated credentials", + "principal": self._target_principal, + } + return None + + def _make_copy(self): + cred = self.__class__( + self._source_credentials, + target_principal=self._target_principal, + target_scopes=self._target_scopes, + delegates=self._delegates, + lifetime=self._lifetime, + quota_project_id=self._quota_project_id, + iam_endpoint_override=self._iam_endpoint_override, + trust_boundary=self._trust_boundary, + ) + cred._cred_file_path = self._cred_file_path + return cred + + @_helpers.copy_docstring(credentials.CredentialsWithTrustBoundary) + def with_trust_boundary(self, trust_boundary): + cred = self._make_copy() + cred._trust_boundary = trust_boundary + return cred + + @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) + def with_quota_project(self, quota_project_id): + cred = self._make_copy() + cred._quota_project_id = quota_project_id + return cred + + @_helpers.copy_docstring(credentials.Scoped) + def with_scopes(self, scopes, default_scopes=None): + cred = self._make_copy() + cred._target_scopes = scopes or default_scopes + return cred + + @classmethod + def from_impersonated_service_account_info(cls, info, scopes=None): + """Creates a Credentials instance from parsed impersonated service account credentials info. + + **IMPORTANT**: + This method does not validate the credential configuration. A security + risk occurs when a credential configuration configured with malicious urls + is used. + When the credential configuration is accepted from an + untrusted source, you should validate it before using with this method. + Refer https://cloud.google.com/docs/authentication/external/externally-sourced-credentials for more details. + + Args: + info (Mapping[str, str]): The impersonated service account credentials info in Google + format. + scopes (Sequence[str]): Optional list of scopes to include in the + credentials. + + Returns: + google.oauth2.credentials.Credentials: The constructed + credentials. + + Raises: + InvalidType: If the info["source_credentials"] are not a supported impersonation type + InvalidValue: If the info["service_account_impersonation_url"] is not in the expected format. + ValueError: If the info is not in the expected format. + """ + + source_credentials_info = info.get("source_credentials") + source_credentials_type = source_credentials_info.get("type") + if source_credentials_type == _SOURCE_CREDENTIAL_AUTHORIZED_USER_TYPE: + from google.oauth2 import credentials + + source_credentials = credentials.Credentials.from_authorized_user_info( + source_credentials_info + ) + elif source_credentials_type == _SOURCE_CREDENTIAL_SERVICE_ACCOUNT_TYPE: + from google.oauth2 import service_account + + source_credentials = service_account.Credentials.from_service_account_info( + source_credentials_info + ) + elif ( + source_credentials_type + == _SOURCE_CREDENTIAL_EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE + ): + from google.auth import external_account_authorized_user + + source_credentials = external_account_authorized_user.Credentials.from_info( + source_credentials_info + ) + else: + raise exceptions.InvalidType( + "source credential of type {} is not supported.".format( + source_credentials_type + ) + ) + + impersonation_url = info.get("service_account_impersonation_url") + start_index = impersonation_url.rfind("/") + end_index = impersonation_url.find(":generateAccessToken") + if start_index == -1 or end_index == -1 or start_index > end_index: + raise exceptions.InvalidValue( + "Cannot extract target principal from {}".format(impersonation_url) + ) + target_principal = impersonation_url[start_index + 1 : end_index] + delegates = info.get("delegates") + quota_project_id = info.get("quota_project_id") + scopes = scopes or info.get("scopes") + trust_boundary = info.get("trust_boundary") + + return cls( + source_credentials, + target_principal, + scopes, + delegates, + quota_project_id=quota_project_id, + trust_boundary=trust_boundary, + ) + + +class IDTokenCredentials(credentials.CredentialsWithQuotaProject): + """Open ID Connect ID Token-based service account credentials.""" + + def __init__( + self, + target_credentials, + target_audience=None, + include_email=False, + quota_project_id=None, + ): + """ + Args: + target_credentials (google.auth.Credentials): The target + credential used as to acquire the id tokens for. + target_audience (string): Audience to issue the token for. + include_email (bool): Include email in IdToken + quota_project_id (Optional[str]): The project ID used for + quota and billing. + """ + super(IDTokenCredentials, self).__init__() + + if not isinstance(target_credentials, Credentials): + raise exceptions.GoogleAuthError( + "Provided Credential must be " "impersonated_credentials" + ) + self._target_credentials = target_credentials + self._target_audience = target_audience + self._include_email = include_email + self._quota_project_id = quota_project_id + + def from_credentials(self, target_credentials, target_audience=None): + return self.__class__( + target_credentials=target_credentials, + target_audience=target_audience, + include_email=self._include_email, + quota_project_id=self._quota_project_id, + ) + + def with_target_audience(self, target_audience): + return self.__class__( + target_credentials=self._target_credentials, + target_audience=target_audience, + include_email=self._include_email, + quota_project_id=self._quota_project_id, + ) + + def with_include_email(self, include_email): + return self.__class__( + target_credentials=self._target_credentials, + target_audience=self._target_audience, + include_email=include_email, + quota_project_id=self._quota_project_id, + ) + + @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) + def with_quota_project(self, quota_project_id): + return self.__class__( + target_credentials=self._target_credentials, + target_audience=self._target_audience, + include_email=self._include_email, + quota_project_id=quota_project_id, + ) + + @_helpers.copy_docstring(credentials.Credentials) + def refresh(self, request): + from google.auth.transport.requests import AuthorizedSession + + iam_sign_endpoint = iam._IAM_IDTOKEN_ENDPOINT.replace( + credentials.DEFAULT_UNIVERSE_DOMAIN, + self._target_credentials.universe_domain, + ).format(self._target_credentials.signer_email) + + body = { + "audience": self._target_audience, + "delegates": self._target_credentials._delegates, + "includeEmail": self._include_email, + } + + headers = { + "Content-Type": "application/json", + metrics.API_CLIENT_HEADER: metrics.token_request_id_token_impersonate(), + } + + authed_session = AuthorizedSession( + self._target_credentials._source_credentials, auth_request=request + ) + + try: + response = authed_session.post( + url=iam_sign_endpoint, + headers=headers, + data=json.dumps(body).encode("utf-8"), + ) + finally: + authed_session.close() + + if response.status_code != http_client.OK: + raise exceptions.RefreshError( + "Error getting ID token: {}".format(response.json()) + ) + + id_token = response.json()["token"] + self.token = id_token + self.expiry = datetime.utcfromtimestamp( + jwt.decode(id_token, verify=False)["exp"] + ) + + +def _sign_jwt_request(request, principal, headers, payload, delegates=[]): + """Makes a request to the Google Cloud IAM service to sign a JWT using a + service account's system-managed private key. + Args: + request (Request): The Request object to use. + principal (str): The principal to request an access token for. + headers (Mapping[str, str]): Map of headers to transmit. + payload (Mapping[str, str]): The JWT payload to sign. Must be a + serialized JSON object that contains a JWT Claims Set. + delegates (Sequence[str]): The chained list of delegates required + to grant the final access_token. If set, the sequence of + identities must have "Service Account Token Creator" capability + granted to the prceeding identity. For example, if set to + [serviceAccountB, serviceAccountC], the source_credential + must have the Token Creator role on serviceAccountB. + serviceAccountB must have the Token Creator on + serviceAccountC. + Finally, C must have Token Creator on target_principal. + If left unset, source_credential must have that role on + target_principal. + + Raises: + google.auth.exceptions.TransportError: Raised if there is an underlying + HTTP connection error + google.auth.exceptions.RefreshError: Raised if the impersonated + credentials are not available. Common reasons are + `iamcredentials.googleapis.com` is not enabled or the + `Service Account Token Creator` is not assigned + """ + iam_endpoint = iam._IAM_SIGNJWT_ENDPOINT.format(principal) + + body = {"delegates": delegates, "payload": json.dumps(payload)} + body = json.dumps(body).encode("utf-8") + + response = request(url=iam_endpoint, method="POST", headers=headers, body=body) + + # support both string and bytes type response.data + response_body = ( + response.data.decode("utf-8") + if hasattr(response.data, "decode") + else response.data + ) + + if response.status != http_client.OK: + raise exceptions.RefreshError(_REFRESH_ERROR, response_body) + + try: + jwt_response = json.loads(response_body) + signed_jwt = jwt_response["signedJwt"] + return signed_jwt + + except (KeyError, ValueError) as caught_exc: + new_exc = exceptions.RefreshError( + "{}: No signed JWT in response.".format(_REFRESH_ERROR), response_body + ) + raise new_exc from caught_exc diff --git a/packages/google-auth/google/auth/jwt.py b/packages/google-auth/google/auth/jwt.py new file mode 100644 index 000000000000..3b28bb5184dd --- /dev/null +++ b/packages/google-auth/google/auth/jwt.py @@ -0,0 +1,878 @@ +# Copyright 2016 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. + +"""JSON Web Tokens + +Provides support for creating (encoding) and verifying (decoding) JWTs, +especially JWTs generated and consumed by Google infrastructure. + +See `rfc7519`_ for more details on JWTs. + +To encode a JWT use :func:`encode`:: + + from google.auth import crypt + from google.auth import jwt + + signer = crypt.Signer(private_key) + payload = {'some': 'payload'} + encoded = jwt.encode(signer, payload) + +To decode a JWT and verify claims use :func:`decode`:: + + claims = jwt.decode(encoded, certs=public_certs) + +You can also skip verification:: + + claims = jwt.decode(encoded, verify=False) + +.. _rfc7519: https://tools.ietf.org/html/rfc7519 + +""" + +try: + from collections.abc import Mapping +# Python 2.7 compatibility +except ImportError: # pragma: NO COVER + from collections import Mapping # type: ignore +import copy +import datetime +import json +import urllib + +import cachetools + +from google.auth import _helpers +from google.auth import _service_account_info +from google.auth import crypt +from google.auth import exceptions +import google.auth.credentials + +try: + from google.auth.crypt import es +except ImportError: # pragma: NO COVER + es = None # type: ignore + +_DEFAULT_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds +_DEFAULT_MAX_CACHE_SIZE = 10 +_ALGORITHM_TO_VERIFIER_CLASS = {"RS256": crypt.RSAVerifier} +_CRYPTOGRAPHY_BASED_ALGORITHMS = frozenset(["ES256", "ES384"]) + +if es is not None: # pragma: NO COVER + _ALGORITHM_TO_VERIFIER_CLASS["ES256"] = es.EsVerifier # type: ignore + _ALGORITHM_TO_VERIFIER_CLASS["ES384"] = es.EsVerifier # type: ignore + + +def encode(signer, payload, header=None, key_id=None): + """Make a signed JWT. + + Args: + signer (google.auth.crypt.Signer): The signer used to sign the JWT. + payload (Mapping[str, str]): The JWT payload. + header (Mapping[str, str]): Additional JWT header payload. + key_id (str): The key id to add to the JWT header. If the + signer has a key id it will be used as the default. If this is + specified it will override the signer's key id. + + Returns: + bytes: The encoded JWT. + """ + if header is None: + header = {} + + if key_id is None: + key_id = signer.key_id + + header.update({"typ": "JWT"}) + + if "alg" not in header: + if es is not None and isinstance(signer, es.EsSigner): + header.update({"alg": signer.algorithm}) + else: + header.update({"alg": "RS256"}) + + if key_id is not None: + header["kid"] = key_id + + segments = [ + _helpers.unpadded_urlsafe_b64encode(json.dumps(header).encode("utf-8")), + _helpers.unpadded_urlsafe_b64encode(json.dumps(payload).encode("utf-8")), + ] + + signing_input = b".".join(segments) + signature = signer.sign(signing_input) + segments.append(_helpers.unpadded_urlsafe_b64encode(signature)) + + return b".".join(segments) + + +def _decode_jwt_segment(encoded_section): + """Decodes a single JWT segment.""" + section_bytes = _helpers.padded_urlsafe_b64decode(encoded_section) + try: + return json.loads(section_bytes.decode("utf-8")) + except ValueError as caught_exc: + new_exc = exceptions.MalformedError( + "Can't parse segment: {0}".format(section_bytes) + ) + raise new_exc from caught_exc + + +def _unverified_decode(token): + """Decodes a token and does no verification. + + Args: + token (Union[str, bytes]): The encoded JWT. + + Returns: + Tuple[Mapping, Mapping, str, str]: header, payload, signed_section, and + signature. + + Raises: + google.auth.exceptions.MalformedError: if there are an incorrect amount of segments in the token or segments of the wrong type. + """ + token = _helpers.to_bytes(token) + + if token.count(b".") != 2: + raise exceptions.MalformedError( + "Wrong number of segments in token: {0}".format(token) + ) + + encoded_header, encoded_payload, signature = token.split(b".") + signed_section = encoded_header + b"." + encoded_payload + signature = _helpers.padded_urlsafe_b64decode(signature) + + # Parse segments + header = _decode_jwt_segment(encoded_header) + payload = _decode_jwt_segment(encoded_payload) + + if not isinstance(header, Mapping): + raise exceptions.MalformedError( + "Header segment should be a JSON object: {0}".format(encoded_header) + ) + + if not isinstance(payload, Mapping): + raise exceptions.MalformedError( + "Payload segment should be a JSON object: {0}".format(encoded_payload) + ) + + return header, payload, signed_section, signature + + +def decode_header(token): + """Return the decoded header of a token. + + No verification is done. This is useful to extract the key id from + the header in order to acquire the appropriate certificate to verify + the token. + + Args: + token (Union[str, bytes]): the encoded JWT. + + Returns: + Mapping: The decoded JWT header. + """ + header, _, _, _ = _unverified_decode(token) + return header + + +def _verify_iat_and_exp(payload, clock_skew_in_seconds=0): + """Verifies the ``iat`` (Issued At) and ``exp`` (Expires) claims in a token + payload. + + Args: + payload (Mapping[str, str]): The JWT payload. + clock_skew_in_seconds (int): The clock skew used for `iat` and `exp` + validation. + + Raises: + google.auth.exceptions.InvalidValue: if value validation failed. + google.auth.exceptions.MalformedError: if schema validation failed. + """ + now = _helpers.datetime_to_secs(_helpers.utcnow()) + + # Make sure the iat and exp claims are present. + for key in ("iat", "exp"): + if key not in payload: + raise exceptions.MalformedError( + "Token does not contain required claim {}".format(key) + ) + + # Make sure the token wasn't issued in the future. + iat = payload["iat"] + # Err on the side of accepting a token that is slightly early to account + # for clock skew. + earliest = iat - clock_skew_in_seconds + if now < earliest: + raise exceptions.InvalidValue( + "Token used too early, {} < {}. Check that your computer's clock is set correctly.".format( + now, iat + ) + ) + + # Make sure the token wasn't issued in the past. + exp = payload["exp"] + # Err on the side of accepting a token that is slightly out of date + # to account for clow skew. + latest = exp + clock_skew_in_seconds + if latest < now: + raise exceptions.InvalidValue("Token expired, {} < {}".format(latest, now)) + + +def decode(token, certs=None, verify=True, audience=None, clock_skew_in_seconds=0): + """Decode and verify a JWT. + + Args: + token (str): The encoded JWT. + certs (Union[str, bytes, Mapping[str, Union[str, bytes]]]): The + certificate used to validate the JWT signature. If bytes or string, + it must the the public key certificate in PEM format. If a mapping, + it must be a mapping of key IDs to public key certificates in PEM + format. The mapping must contain the same key ID that's specified + in the token's header. + verify (bool): Whether to perform signature and claim validation. + Verification is done by default. + audience (str or list): The audience claim, 'aud', that this JWT should + contain. Or a list of audience claims. If None then the JWT's 'aud' + parameter is not verified. + clock_skew_in_seconds (int): The clock skew used for `iat` and `exp` + validation. + + Returns: + Mapping[str, str]: The deserialized JSON payload in the JWT. + + Raises: + google.auth.exceptions.InvalidValue: if value validation failed. + google.auth.exceptions.MalformedError: if schema validation failed. + """ + header, payload, signed_section, signature = _unverified_decode(token) + + if not verify: + return payload + + # Pluck the key id and algorithm from the header and make sure we have + # a verifier that can support it. + key_alg = header.get("alg") + key_id = header.get("kid") + + try: + verifier_cls = _ALGORITHM_TO_VERIFIER_CLASS[key_alg] + except KeyError as exc: + if key_alg in _CRYPTOGRAPHY_BASED_ALGORITHMS: + raise exceptions.InvalidValue( + "The key algorithm {} requires the cryptography package to be installed.".format( + key_alg + ) + ) from exc + else: + raise exceptions.InvalidValue( + "Unsupported signature algorithm {}".format(key_alg) + ) from exc + # If certs is specified as a dictionary of key IDs to certificates, then + # use the certificate identified by the key ID in the token header. + if isinstance(certs, Mapping): + if key_id: + if key_id not in certs: + raise exceptions.MalformedError( + "Certificate for key id {} not found.".format(key_id) + ) + certs_to_check = [certs[key_id]] + # If there's no key id in the header, check against all of the certs. + else: + certs_to_check = certs.values() + else: + certs_to_check = certs + + # Verify that the signature matches the message. + if not crypt.verify_signature( + signed_section, signature, certs_to_check, verifier_cls + ): + raise exceptions.MalformedError("Could not verify token signature.") + + # Verify the issued at and created times in the payload. + _verify_iat_and_exp(payload, clock_skew_in_seconds) + + # Check audience. + if audience is not None: + claim_audience = payload.get("aud") + if isinstance(audience, str): + audience = [audience] + if claim_audience not in audience: + raise exceptions.InvalidValue( + "Token has wrong audience {}, expected one of {}".format( + claim_audience, audience + ) + ) + + return payload + + +class Credentials( + google.auth.credentials.Signing, google.auth.credentials.CredentialsWithQuotaProject +): + """Credentials that use a JWT as the bearer token. + + These credentials require an "audience" claim. This claim identifies the + intended recipient of the bearer token. + + The constructor arguments determine the claims for the JWT that is + sent with requests. Usually, you'll construct these credentials with + one of the helper constructors as shown in the next section. + + To create JWT credentials using a Google service account private key + JSON file:: + + audience = 'https://pubsub.googleapis.com/google.pubsub.v1.Publisher' + credentials = jwt.Credentials.from_service_account_file( + 'service-account.json', + audience=audience) + + If you already have the service account file loaded and parsed:: + + service_account_info = json.load(open('service_account.json')) + credentials = jwt.Credentials.from_service_account_info( + service_account_info, + audience=audience) + + Both helper methods pass on arguments to the constructor, so you can + specify the JWT claims:: + + credentials = jwt.Credentials.from_service_account_file( + 'service-account.json', + audience=audience, + additional_claims={'meta': 'data'}) + + You can also construct the credentials directly if you have a + :class:`~google.auth.crypt.Signer` instance:: + + credentials = jwt.Credentials( + signer, + issuer='your-issuer', + subject='your-subject', + audience=audience) + + The claims are considered immutable. If you want to modify the claims, + you can easily create another instance using :meth:`with_claims`:: + + new_audience = ( + 'https://pubsub.googleapis.com/google.pubsub.v1.Subscriber') + new_credentials = credentials.with_claims(audience=new_audience) + """ + + def __init__( + self, + signer, + issuer, + subject, + audience, + additional_claims=None, + token_lifetime=_DEFAULT_TOKEN_LIFETIME_SECS, + quota_project_id=None, + ): + """ + Args: + signer (google.auth.crypt.Signer): The signer used to sign JWTs. + issuer (str): The `iss` claim. + subject (str): The `sub` claim. + audience (str): the `aud` claim. The intended audience for the + credentials. + additional_claims (Mapping[str, str]): Any additional claims for + the JWT payload. + token_lifetime (int): The amount of time in seconds for + which the token is valid. Defaults to 1 hour. + quota_project_id (Optional[str]): The project ID used for quota + and billing. + """ + super(Credentials, self).__init__() + self._signer = signer + self._issuer = issuer + self._subject = subject + self._audience = audience + self._token_lifetime = token_lifetime + self._quota_project_id = quota_project_id + + if additional_claims is None: + additional_claims = {} + + self._additional_claims = additional_claims + + @classmethod + def _from_signer_and_info(cls, signer, info, **kwargs): + """Creates a Credentials instance from a signer and service account + info. + + Args: + signer (google.auth.crypt.Signer): The signer used to sign JWTs. + info (Mapping[str, str]): The service account info. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.jwt.Credentials: The constructed credentials. + + Raises: + google.auth.exceptions.MalformedError: If the info is not in the expected format. + """ + kwargs.setdefault("subject", info["client_email"]) + kwargs.setdefault("issuer", info["client_email"]) + return cls(signer, **kwargs) + + @classmethod + def from_service_account_info(cls, info, **kwargs): + """Creates an Credentials instance from a dictionary. + + Args: + info (Mapping[str, str]): The service account info in Google + format. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.jwt.Credentials: The constructed credentials. + + Raises: + google.auth.exceptions.MalformedError: If the info is not in the expected format. + """ + signer = _service_account_info.from_dict(info, require=["client_email"]) + return cls._from_signer_and_info(signer, info, **kwargs) + + @classmethod + def from_service_account_file(cls, filename, **kwargs): + """Creates a Credentials instance from a service account .json file + in Google format. + + Args: + filename (str): The path to the service account .json file. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.jwt.Credentials: The constructed credentials. + """ + info, signer = _service_account_info.from_filename( + filename, require=["client_email"] + ) + return cls._from_signer_and_info(signer, info, **kwargs) + + @classmethod + def from_signing_credentials(cls, credentials, audience, **kwargs): + """Creates a new :class:`google.auth.jwt.Credentials` instance from an + existing :class:`google.auth.credentials.Signing` instance. + + The new instance will use the same signer as the existing instance and + will use the existing instance's signer email as the issuer and + subject by default. + + Example:: + + svc_creds = service_account.Credentials.from_service_account_file( + 'service_account.json') + audience = ( + 'https://pubsub.googleapis.com/google.pubsub.v1.Publisher') + jwt_creds = jwt.Credentials.from_signing_credentials( + svc_creds, audience=audience) + + Args: + credentials (google.auth.credentials.Signing): The credentials to + use to construct the new credentials. + audience (str): the `aud` claim. The intended audience for the + credentials. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.jwt.Credentials: A new Credentials instance. + """ + kwargs.setdefault("issuer", credentials.signer_email) + kwargs.setdefault("subject", credentials.signer_email) + return cls(credentials.signer, audience=audience, **kwargs) + + def with_claims( + self, issuer=None, subject=None, audience=None, additional_claims=None + ): + """Returns a copy of these credentials with modified claims. + + Args: + issuer (str): The `iss` claim. If unspecified the current issuer + claim will be used. + subject (str): The `sub` claim. If unspecified the current subject + claim will be used. + audience (str): the `aud` claim. If unspecified the current + audience claim will be used. + additional_claims (Mapping[str, str]): Any additional claims for + the JWT payload. This will be merged with the current + additional claims. + + Returns: + google.auth.jwt.Credentials: A new credentials instance. + """ + new_additional_claims = copy.deepcopy(self._additional_claims) + new_additional_claims.update(additional_claims or {}) + + return self.__class__( + self._signer, + issuer=issuer if issuer is not None else self._issuer, + subject=subject if subject is not None else self._subject, + audience=audience if audience is not None else self._audience, + additional_claims=new_additional_claims, + quota_project_id=self._quota_project_id, + ) + + @_helpers.copy_docstring(google.auth.credentials.CredentialsWithQuotaProject) + def with_quota_project(self, quota_project_id): + return self.__class__( + self._signer, + issuer=self._issuer, + subject=self._subject, + audience=self._audience, + additional_claims=self._additional_claims, + quota_project_id=quota_project_id, + ) + + def _make_jwt(self): + """Make a signed JWT. + + Returns: + Tuple[bytes, datetime]: The encoded JWT and the expiration. + """ + now = _helpers.utcnow() + lifetime = datetime.timedelta(seconds=self._token_lifetime) + expiry = now + lifetime + + payload = { + "iss": self._issuer, + "sub": self._subject, + "iat": _helpers.datetime_to_secs(now), + "exp": _helpers.datetime_to_secs(expiry), + } + if self._audience: + payload["aud"] = self._audience + + payload.update(self._additional_claims) + + jwt = encode(self._signer, payload) + + return jwt, expiry + + def refresh(self, request): + """Refreshes the access token. + + Args: + request (Any): Unused. + """ + # pylint: disable=unused-argument + # (pylint doesn't correctly recognize overridden methods.) + self.token, self.expiry = self._make_jwt() + + @_helpers.copy_docstring(google.auth.credentials.Signing) + def sign_bytes(self, message): + return self._signer.sign(message) + + @property # type: ignore + @_helpers.copy_docstring(google.auth.credentials.Signing) + def signer_email(self): + return self._issuer + + @property # type: ignore + @_helpers.copy_docstring(google.auth.credentials.Signing) + def signer(self): + return self._signer + + @property # type: ignore + def additional_claims(self): + """Additional claims the JWT object was created with.""" + return self._additional_claims + + +class OnDemandCredentials( + google.auth.credentials.Signing, google.auth.credentials.CredentialsWithQuotaProject +): + """On-demand JWT credentials. + + Like :class:`Credentials`, this class uses a JWT as the bearer token for + authentication. However, this class does not require the audience at + construction time. Instead, it will generate a new token on-demand for + each request using the request URI as the audience. It caches tokens + so that multiple requests to the same URI do not incur the overhead + of generating a new token every time. + + This behavior is especially useful for `gRPC`_ clients. A gRPC service may + have multiple audience and gRPC clients may not know all of the audiences + required for accessing a particular service. With these credentials, + no knowledge of the audiences is required ahead of time. + + .. _grpc: http://www.grpc.io/ + """ + + def __init__( + self, + signer, + issuer, + subject, + additional_claims=None, + token_lifetime=_DEFAULT_TOKEN_LIFETIME_SECS, + max_cache_size=_DEFAULT_MAX_CACHE_SIZE, + quota_project_id=None, + ): + """ + Args: + signer (google.auth.crypt.Signer): The signer used to sign JWTs. + issuer (str): The `iss` claim. + subject (str): The `sub` claim. + additional_claims (Mapping[str, str]): Any additional claims for + the JWT payload. + token_lifetime (int): The amount of time in seconds for + which the token is valid. Defaults to 1 hour. + max_cache_size (int): The maximum number of JWT tokens to keep in + cache. Tokens are cached using :class:`cachetools.LRUCache`. + quota_project_id (Optional[str]): The project ID used for quota + and billing. + + """ + super(OnDemandCredentials, self).__init__() + self._signer = signer + self._issuer = issuer + self._subject = subject + self._token_lifetime = token_lifetime + self._quota_project_id = quota_project_id + + if additional_claims is None: + additional_claims = {} + + self._additional_claims = additional_claims + self._cache = cachetools.LRUCache(maxsize=max_cache_size) + + @classmethod + def _from_signer_and_info(cls, signer, info, **kwargs): + """Creates an OnDemandCredentials instance from a signer and service + account info. + + Args: + signer (google.auth.crypt.Signer): The signer used to sign JWTs. + info (Mapping[str, str]): The service account info. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.jwt.OnDemandCredentials: The constructed credentials. + + Raises: + google.auth.exceptions.MalformedError: If the info is not in the expected format. + """ + kwargs.setdefault("subject", info["client_email"]) + kwargs.setdefault("issuer", info["client_email"]) + return cls(signer, **kwargs) + + @classmethod + def from_service_account_info(cls, info, **kwargs): + """Creates an OnDemandCredentials instance from a dictionary. + + Args: + info (Mapping[str, str]): The service account info in Google + format. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.jwt.OnDemandCredentials: The constructed credentials. + + Raises: + google.auth.exceptions.MalformedError: If the info is not in the expected format. + """ + signer = _service_account_info.from_dict(info, require=["client_email"]) + return cls._from_signer_and_info(signer, info, **kwargs) + + @classmethod + def from_service_account_file(cls, filename, **kwargs): + """Creates an OnDemandCredentials instance from a service account .json + file in Google format. + + Args: + filename (str): The path to the service account .json file. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.jwt.OnDemandCredentials: The constructed credentials. + """ + info, signer = _service_account_info.from_filename( + filename, require=["client_email"] + ) + return cls._from_signer_and_info(signer, info, **kwargs) + + @classmethod + def from_signing_credentials(cls, credentials, **kwargs): + """Creates a new :class:`google.auth.jwt.OnDemandCredentials` instance + from an existing :class:`google.auth.credentials.Signing` instance. + + The new instance will use the same signer as the existing instance and + will use the existing instance's signer email as the issuer and + subject by default. + + Example:: + + svc_creds = service_account.Credentials.from_service_account_file( + 'service_account.json') + jwt_creds = jwt.OnDemandCredentials.from_signing_credentials( + svc_creds) + + Args: + credentials (google.auth.credentials.Signing): The credentials to + use to construct the new credentials. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.jwt.Credentials: A new Credentials instance. + """ + kwargs.setdefault("issuer", credentials.signer_email) + kwargs.setdefault("subject", credentials.signer_email) + return cls(credentials.signer, **kwargs) + + def with_claims(self, issuer=None, subject=None, additional_claims=None): + """Returns a copy of these credentials with modified claims. + + Args: + issuer (str): The `iss` claim. If unspecified the current issuer + claim will be used. + subject (str): The `sub` claim. If unspecified the current subject + claim will be used. + additional_claims (Mapping[str, str]): Any additional claims for + the JWT payload. This will be merged with the current + additional claims. + + Returns: + google.auth.jwt.OnDemandCredentials: A new credentials instance. + """ + new_additional_claims = copy.deepcopy(self._additional_claims) + new_additional_claims.update(additional_claims or {}) + + return self.__class__( + self._signer, + issuer=issuer if issuer is not None else self._issuer, + subject=subject if subject is not None else self._subject, + additional_claims=new_additional_claims, + max_cache_size=self._cache.maxsize, + quota_project_id=self._quota_project_id, + ) + + @_helpers.copy_docstring(google.auth.credentials.CredentialsWithQuotaProject) + def with_quota_project(self, quota_project_id): + return self.__class__( + self._signer, + issuer=self._issuer, + subject=self._subject, + additional_claims=self._additional_claims, + max_cache_size=self._cache.maxsize, + quota_project_id=quota_project_id, + ) + + @property + def valid(self): + """Checks the validity of the credentials. + + These credentials are always valid because it generates tokens on + demand. + """ + return True + + def _make_jwt_for_audience(self, audience): + """Make a new JWT for the given audience. + + Args: + audience (str): The intended audience. + + Returns: + Tuple[bytes, datetime]: The encoded JWT and the expiration. + """ + now = _helpers.utcnow() + lifetime = datetime.timedelta(seconds=self._token_lifetime) + expiry = now + lifetime + + payload = { + "iss": self._issuer, + "sub": self._subject, + "iat": _helpers.datetime_to_secs(now), + "exp": _helpers.datetime_to_secs(expiry), + "aud": audience, + } + + payload.update(self._additional_claims) + + jwt = encode(self._signer, payload) + + return jwt, expiry + + def _get_jwt_for_audience(self, audience): + """Get a JWT For a given audience. + + If there is already an existing, non-expired token in the cache for + the audience, that token is used. Otherwise, a new token will be + created. + + Args: + audience (str): The intended audience. + + Returns: + bytes: The encoded JWT. + """ + token, expiry = self._cache.get(audience, (None, None)) + + if token is None or expiry < _helpers.utcnow(): + token, expiry = self._make_jwt_for_audience(audience) + self._cache[audience] = token, expiry + + return token + + def refresh(self, request): + """Raises an exception, these credentials can not be directly + refreshed. + + Args: + request (Any): Unused. + + Raises: + google.auth.RefreshError + """ + # pylint: disable=unused-argument + # (pylint doesn't correctly recognize overridden methods.) + raise exceptions.RefreshError( + "OnDemandCredentials can not be directly refreshed." + ) + + def before_request(self, request, method, url, headers): + """Performs credential-specific before request logic. + + Args: + request (Any): Unused. JWT credentials do not need to make an + HTTP request to refresh. + method (str): The request's HTTP method. + url (str): The request's URI. This is used as the audience claim + when generating the JWT. + headers (Mapping): The request's headers. + """ + # pylint: disable=unused-argument + # (pylint doesn't correctly recognize overridden methods.) + parts = urllib.parse.urlsplit(url) + # Strip query string and fragment + audience = urllib.parse.urlunsplit( + (parts.scheme, parts.netloc, parts.path, "", "") + ) + token = self._get_jwt_for_audience(audience) + self.apply(headers, token=token) + + @_helpers.copy_docstring(google.auth.credentials.Signing) + def sign_bytes(self, message): + return self._signer.sign(message) + + @property # type: ignore + @_helpers.copy_docstring(google.auth.credentials.Signing) + def signer_email(self): + return self._issuer + + @property # type: ignore + @_helpers.copy_docstring(google.auth.credentials.Signing) + def signer(self): + return self._signer diff --git a/packages/google-auth/google/auth/metrics.py b/packages/google-auth/google/auth/metrics.py new file mode 100644 index 000000000000..5511f581f658 --- /dev/null +++ b/packages/google-auth/google/auth/metrics.py @@ -0,0 +1,156 @@ +# Copyright 2023 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. + +""" We use x-goog-api-client header to report metrics. This module provides +the constants and helper methods to construct x-goog-api-client header. +""" + +import platform + +from google.auth import version + + +API_CLIENT_HEADER = "x-goog-api-client" + +# BYOID Specific consts +BYOID_HEADER_SECTION = "google-byoid-sdk" + +# Auth request type +REQUEST_TYPE_ACCESS_TOKEN = "auth-request-type/at" +REQUEST_TYPE_ID_TOKEN = "auth-request-type/it" +REQUEST_TYPE_MDS_PING = "auth-request-type/mds" +REQUEST_TYPE_REAUTH_START = "auth-request-type/re-start" +REQUEST_TYPE_REAUTH_CONTINUE = "auth-request-type/re-cont" + +# Credential type +CRED_TYPE_USER = "cred-type/u" +CRED_TYPE_SA_ASSERTION = "cred-type/sa" +CRED_TYPE_SA_JWT = "cred-type/jwt" +CRED_TYPE_SA_MDS = "cred-type/mds" +CRED_TYPE_SA_IMPERSONATE = "cred-type/imp" + + +# Versions +def python_and_auth_lib_version(): + return "gl-python/{} auth/{}".format(platform.python_version(), version.__version__) + + +# Token request metric header values + + +# x-goog-api-client header value for access token request via metadata server. +# Example: "gl-python/3.7 auth/1.1 auth-request-type/at cred-type/mds" +def token_request_access_token_mds(): + return "{} {} {}".format( + python_and_auth_lib_version(), REQUEST_TYPE_ACCESS_TOKEN, CRED_TYPE_SA_MDS + ) + + +# x-goog-api-client header value for ID token request via metadata server. +# Example: "gl-python/3.7 auth/1.1 auth-request-type/it cred-type/mds" +def token_request_id_token_mds(): + return "{} {} {}".format( + python_and_auth_lib_version(), REQUEST_TYPE_ID_TOKEN, CRED_TYPE_SA_MDS + ) + + +# x-goog-api-client header value for impersonated credentials access token request. +# Example: "gl-python/3.7 auth/1.1 auth-request-type/at cred-type/imp" +def token_request_access_token_impersonate(): + return "{} {} {}".format( + python_and_auth_lib_version(), + REQUEST_TYPE_ACCESS_TOKEN, + CRED_TYPE_SA_IMPERSONATE, + ) + + +# x-goog-api-client header value for impersonated credentials ID token request. +# Example: "gl-python/3.7 auth/1.1 auth-request-type/it cred-type/imp" +def token_request_id_token_impersonate(): + return "{} {} {}".format( + python_and_auth_lib_version(), REQUEST_TYPE_ID_TOKEN, CRED_TYPE_SA_IMPERSONATE + ) + + +# x-goog-api-client header value for service account credentials access token +# request (assertion flow). +# Example: "gl-python/3.7 auth/1.1 auth-request-type/at cred-type/sa" +def token_request_access_token_sa_assertion(): + return "{} {} {}".format( + python_and_auth_lib_version(), REQUEST_TYPE_ACCESS_TOKEN, CRED_TYPE_SA_ASSERTION + ) + + +# x-goog-api-client header value for service account credentials ID token +# request (assertion flow). +# Example: "gl-python/3.7 auth/1.1 auth-request-type/it cred-type/sa" +def token_request_id_token_sa_assertion(): + return "{} {} {}".format( + python_and_auth_lib_version(), REQUEST_TYPE_ID_TOKEN, CRED_TYPE_SA_ASSERTION + ) + + +# x-goog-api-client header value for user credentials token request. +# Example: "gl-python/3.7 auth/1.1 cred-type/u" +def token_request_user(): + return "{} {}".format(python_and_auth_lib_version(), CRED_TYPE_USER) + + +# Miscellenous metrics + + +# x-goog-api-client header value for metadata server ping. +# Example: "gl-python/3.7 auth/1.1 auth-request-type/mds" +def mds_ping(): + return "{} {}".format(python_and_auth_lib_version(), REQUEST_TYPE_MDS_PING) + + +# x-goog-api-client header value for reauth start endpoint calls. +# Example: "gl-python/3.7 auth/1.1 auth-request-type/re-start" +def reauth_start(): + return "{} {}".format(python_and_auth_lib_version(), REQUEST_TYPE_REAUTH_START) + + +# x-goog-api-client header value for reauth continue endpoint calls. +# Example: "gl-python/3.7 auth/1.1 cred-type/re-cont" +def reauth_continue(): + return "{} {}".format(python_and_auth_lib_version(), REQUEST_TYPE_REAUTH_CONTINUE) + + +# x-goog-api-client header value for BYOID calls to the Security Token Service exchange token endpoint. +# Example: "gl-python/3.7 auth/1.1 google-byoid-sdk source/aws sa-impersonation/true sa-impersonation/true" +def byoid_metrics_header(metrics_options): + header = "{} {}".format(python_and_auth_lib_version(), BYOID_HEADER_SECTION) + for key, value in metrics_options.items(): + header = "{} {}/{}".format(header, key, value) + return header + + +def add_metric_header(headers, metric_header_value): + """Add x-goog-api-client header with the given value. + + Args: + headers (Mapping[str, str]): The headers to which we will add the + metric header. + metric_header_value (Optional[str]): If value is None, do nothing; + if headers already has a x-goog-api-client header, append the value + to the existing header; otherwise add a new x-goog-api-client + header with the given value. + """ + if not metric_header_value: + return + if API_CLIENT_HEADER not in headers: + headers[API_CLIENT_HEADER] = metric_header_value + else: + headers[API_CLIENT_HEADER] += " " + metric_header_value diff --git a/packages/google-auth/google/auth/pluggable.py b/packages/google-auth/google/auth/pluggable.py new file mode 100644 index 000000000000..87d03e737a9a --- /dev/null +++ b/packages/google-auth/google/auth/pluggable.py @@ -0,0 +1,455 @@ +# 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. + +"""Pluggable Credentials. +Pluggable Credentials are initialized using external_account arguments which +are typically loaded from third-party executables. Unlike other +credentials that can be initialized with a list of explicit arguments, secrets +or credentials, external account clients use the environment and hints/guidelines +provided by the external_account JSON file to retrieve credentials and exchange +them for Google access tokens. + +Example credential_source for pluggable credential: +{ + "executable": { + "command": "/path/to/get/credentials.sh --arg1=value1 --arg2=value2", + "timeout_millis": 5000, + "output_file": "/path/to/generated/cached/credentials" + } +} +""" + +try: + from collections.abc import Mapping +# Python 2.7 compatibility +except ImportError: # pragma: NO COVER + from collections import Mapping # type: ignore +import json +import os +import shlex +import subprocess +import sys +import time + +from google.auth import _helpers +from google.auth import exceptions +from google.auth import external_account + +# The max supported executable spec version. +EXECUTABLE_SUPPORTED_MAX_VERSION = 1 + +EXECUTABLE_TIMEOUT_MILLIS_DEFAULT = 30 * 1000 # 30 seconds +EXECUTABLE_TIMEOUT_MILLIS_LOWER_BOUND = 5 * 1000 # 5 seconds +EXECUTABLE_TIMEOUT_MILLIS_UPPER_BOUND = 120 * 1000 # 2 minutes + +EXECUTABLE_INTERACTIVE_TIMEOUT_MILLIS_LOWER_BOUND = 30 * 1000 # 30 seconds +EXECUTABLE_INTERACTIVE_TIMEOUT_MILLIS_UPPER_BOUND = 30 * 60 * 1000 # 30 minutes + + +class Credentials(external_account.Credentials): + """External account credentials sourced from executables. + + **IMPORTANT**: + This class does not validate the credential configuration. A security + risk occurs when a credential configuration configured with malicious urls + is used. + When the credential configuration is accepted from an + untrusted source, you should validate it before using. + Refer https://cloud.google.com/docs/authentication/external/externally-sourced-credentials for more details. + """ + + def __init__( + self, + audience, + subject_token_type, + token_url, + credential_source, + *args, + **kwargs + ): + """Instantiates an external account credentials object from a executables. + + Args: + audience (str): The STS audience field. + subject_token_type (str): The subject token type. + token_url (str): The STS endpoint URL. + credential_source (Mapping): The credential source dictionary used to + provide instructions on how to retrieve external credential to be + exchanged for Google access tokens. + + Example credential_source for pluggable credential: + + { + "executable": { + "command": "/path/to/get/credentials.sh --arg1=value1 --arg2=value2", + "timeout_millis": 5000, + "output_file": "/path/to/generated/cached/credentials" + } + } + args (List): Optional positional arguments passed into the underlying :meth:`~external_account.Credentials.__init__` method. + kwargs (Mapping): Optional keyword arguments passed into the underlying :meth:`~external_account.Credentials.__init__` method. + + Raises: + google.auth.exceptions.RefreshError: If an error is encountered during + access token retrieval logic. + google.auth.exceptions.InvalidValue: For invalid parameters. + google.auth.exceptions.MalformedError: For invalid parameters. + + .. note:: Typically one of the helper constructors + :meth:`from_file` or + :meth:`from_info` are used instead of calling the constructor directly. + """ + + self.interactive = kwargs.pop("interactive", False) + super(Credentials, self).__init__( + audience=audience, + subject_token_type=subject_token_type, + token_url=token_url, + credential_source=credential_source, + *args, + **kwargs + ) + if not isinstance(credential_source, Mapping): + self._credential_source_executable = None + raise exceptions.MalformedError( + "Missing credential_source. The credential_source is not a dict." + ) + self._credential_source_executable = credential_source.get("executable") + if not self._credential_source_executable: + raise exceptions.MalformedError( + "Missing credential_source. An 'executable' must be provided." + ) + self._credential_source_executable_command = ( + self._credential_source_executable.get("command") + ) + self._credential_source_executable_timeout_millis = ( + self._credential_source_executable.get("timeout_millis") + ) + self._credential_source_executable_interactive_timeout_millis = ( + self._credential_source_executable.get("interactive_timeout_millis") + ) + self._credential_source_executable_output_file = ( + self._credential_source_executable.get("output_file") + ) + + # Dummy value. This variable is only used via injection, not exposed to ctor + self._tokeninfo_username = "" + + if not self._credential_source_executable_command: + raise exceptions.MalformedError( + "Missing command field. Executable command must be provided." + ) + if not self._credential_source_executable_timeout_millis: + self._credential_source_executable_timeout_millis = ( + EXECUTABLE_TIMEOUT_MILLIS_DEFAULT + ) + elif ( + self._credential_source_executable_timeout_millis + < EXECUTABLE_TIMEOUT_MILLIS_LOWER_BOUND + or self._credential_source_executable_timeout_millis + > EXECUTABLE_TIMEOUT_MILLIS_UPPER_BOUND + ): + raise exceptions.InvalidValue("Timeout must be between 5 and 120 seconds.") + + if self._credential_source_executable_interactive_timeout_millis: + if ( + self._credential_source_executable_interactive_timeout_millis + < EXECUTABLE_INTERACTIVE_TIMEOUT_MILLIS_LOWER_BOUND + or self._credential_source_executable_interactive_timeout_millis + > EXECUTABLE_INTERACTIVE_TIMEOUT_MILLIS_UPPER_BOUND + ): + raise exceptions.InvalidValue( + "Interactive timeout must be between 30 seconds and 30 minutes." + ) + + @_helpers.copy_docstring(external_account.Credentials) + def retrieve_subject_token(self, request): + self._validate_running_mode() + + # Check output file. + if self._credential_source_executable_output_file is not None: + try: + with open( + self._credential_source_executable_output_file, encoding="utf-8" + ) as output_file: + response = json.load(output_file) + except Exception: + pass + else: + try: + # If the cached response is expired, _parse_subject_token will raise an error which will be ignored and we will call the executable again. + subject_token = self._parse_subject_token(response) + if ( + "expiration_time" not in response + ): # Always treat missing expiration_time as expired and proceed to executable run. + raise exceptions.RefreshError + except (exceptions.MalformedError, exceptions.InvalidValue): + raise + except exceptions.RefreshError: + pass + else: + return subject_token + + if not _helpers.is_python_3(): + raise exceptions.RefreshError( + "Pluggable auth is only supported for python 3.7+" + ) + + # Inject env vars. + env = os.environ.copy() + self._inject_env_variables(env) + env["GOOGLE_EXTERNAL_ACCOUNT_REVOKE"] = "0" + + # Run executable. + exe_timeout = ( + self._credential_source_executable_interactive_timeout_millis / 1000 + if self.interactive + else self._credential_source_executable_timeout_millis / 1000 + ) + exe_stdin = sys.stdin if self.interactive else None + exe_stdout = sys.stdout if self.interactive else subprocess.PIPE + exe_stderr = sys.stdout if self.interactive else subprocess.STDOUT + + result = subprocess.run( + shlex.split(self._credential_source_executable_command), + timeout=exe_timeout, + stdin=exe_stdin, + stdout=exe_stdout, + stderr=exe_stderr, + env=env, + ) + if result.returncode != 0: + raise exceptions.RefreshError( + "Executable exited with non-zero return code {}. Error: {}".format( + result.returncode, result.stdout + ) + ) + + # Handle executable output. + response = json.loads(result.stdout.decode("utf-8")) if result.stdout else None + if not response and self._credential_source_executable_output_file is not None: + response = json.load( + open(self._credential_source_executable_output_file, encoding="utf-8") + ) + + subject_token = self._parse_subject_token(response) + return subject_token + + def revoke(self, request): + """Revokes the subject token using the credential_source object. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + Raises: + google.auth.exceptions.RefreshError: If the executable revocation + not properly executed. + + """ + if not self.interactive: + raise exceptions.InvalidValue( + "Revoke is only enabled under interactive mode." + ) + self._validate_running_mode() + + if not _helpers.is_python_3(): + raise exceptions.RefreshError( + "Pluggable auth is only supported for python 3.7+" + ) + + # Inject variables + env = os.environ.copy() + self._inject_env_variables(env) + env["GOOGLE_EXTERNAL_ACCOUNT_REVOKE"] = "1" + + # Run executable + result = subprocess.run( + shlex.split(self._credential_source_executable_command), + timeout=self._credential_source_executable_interactive_timeout_millis + / 1000, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + env=env, + ) + + if result.returncode != 0: + raise exceptions.RefreshError( + "Auth revoke failed on executable. Exit with non-zero return code {}. Error: {}".format( + result.returncode, result.stdout + ) + ) + + response = json.loads(result.stdout.decode("utf-8")) + self._validate_revoke_response(response) + + @property + def external_account_id(self): + """Returns the external account identifier. + + When service account impersonation is used the identifier is the service + account email. + + Without service account impersonation, this returns None, unless it is + being used by the Google Cloud CLI which populates this field. + """ + + return self.service_account_email or self._tokeninfo_username + + @classmethod + def from_info(cls, info, **kwargs): + """Creates a Pluggable Credentials instance from parsed external account info. + + **IMPORTANT**: + This method does not validate the credential configuration. A security + risk occurs when a credential configuration configured with malicious urls + is used. + When the credential configuration is accepted from an + untrusted source, you should validate it before using with this method. + Refer https://cloud.google.com/docs/authentication/external/externally-sourced-credentials for more details. + + Args: + info (Mapping[str, str]): The Pluggable external account info in Google + format. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.pluggable.Credentials: The constructed + credentials. + + Raises: + google.auth.exceptions.InvalidValue: For invalid parameters. + google.auth.exceptions.MalformedError: For invalid parameters. + """ + return super(Credentials, cls).from_info(info, **kwargs) + + @classmethod + def from_file(cls, filename, **kwargs): + """Creates an Pluggable Credentials instance from an external account json file. + + **IMPORTANT**: + This method does not validate the credential configuration. A security + risk occurs when a credential configuration configured with malicious urls + is used. + When the credential configuration is accepted from an + untrusted source, you should validate it before using with this method. + Refer https://cloud.google.com/docs/authentication/external/externally-sourced-credentials for more details. + + Args: + filename (str): The path to the Pluggable external account json file. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.pluggable.Credentials: The constructed + credentials. + """ + return super(Credentials, cls).from_file(filename, **kwargs) + + def _inject_env_variables(self, env): + env["GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE"] = self._audience + env["GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE"] = self._subject_token_type + env["GOOGLE_EXTERNAL_ACCOUNT_ID"] = self.external_account_id + env["GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE"] = "1" if self.interactive else "0" + + if self._service_account_impersonation_url is not None: + env[ + "GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL" + ] = self.service_account_email + if self._credential_source_executable_output_file is not None: + env[ + "GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE" + ] = self._credential_source_executable_output_file + + def _parse_subject_token(self, response): + self._validate_response_schema(response) + if not response["success"]: + if "code" not in response or "message" not in response: + raise exceptions.MalformedError( + "Error code and message fields are required in the response." + ) + raise exceptions.RefreshError( + "Executable returned unsuccessful response: code: {}, message: {}.".format( + response["code"], response["message"] + ) + ) + if "expiration_time" in response and response["expiration_time"] < time.time(): + raise exceptions.RefreshError( + "The token returned by the executable is expired." + ) + if "token_type" not in response: + raise exceptions.MalformedError( + "The executable response is missing the token_type field." + ) + if ( + response["token_type"] == "urn:ietf:params:oauth:token-type:jwt" + or response["token_type"] == "urn:ietf:params:oauth:token-type:id_token" + ): # OIDC + return response["id_token"] + elif response["token_type"] == "urn:ietf:params:oauth:token-type:saml2": # SAML + return response["saml_response"] + else: + raise exceptions.RefreshError("Executable returned unsupported token type.") + + def _validate_revoke_response(self, response): + self._validate_response_schema(response) + if not response["success"]: + raise exceptions.RefreshError("Revoke failed with unsuccessful response.") + + def _validate_response_schema(self, response): + if "version" not in response: + raise exceptions.MalformedError( + "The executable response is missing the version field." + ) + if response["version"] > EXECUTABLE_SUPPORTED_MAX_VERSION: + raise exceptions.RefreshError( + "Executable returned unsupported version {}.".format( + response["version"] + ) + ) + + if "success" not in response: + raise exceptions.MalformedError( + "The executable response is missing the success field." + ) + + def _validate_running_mode(self): + env_allow_executables = os.environ.get( + "GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES" + ) + if env_allow_executables != "1": + raise exceptions.MalformedError( + "Executables need to be explicitly allowed (set GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES to '1') to run." + ) + + if self.interactive and not self._credential_source_executable_output_file: + raise exceptions.MalformedError( + "An output_file must be specified in the credential configuration for interactive mode." + ) + + if ( + self.interactive + and not self._credential_source_executable_interactive_timeout_millis + ): + raise exceptions.InvalidOperation( + "Interactive mode cannot run without an interactive timeout." + ) + + if self.interactive and not self.is_workforce_pool: + raise exceptions.InvalidValue( + "Interactive mode is only enabled for workforce pool." + ) + + def _create_default_metrics_options(self): + metrics_options = super(Credentials, self)._create_default_metrics_options() + metrics_options["source"] = "executable" + return metrics_options diff --git a/packages/google-auth/google/auth/py.typed b/packages/google-auth/google/auth/py.typed new file mode 100644 index 000000000000..aa7b68923f2b --- /dev/null +++ b/packages/google-auth/google/auth/py.typed @@ -0,0 +1,2 @@ +# Marker file for PEP 561. +# The google-auth package uses inline types. diff --git a/packages/google-auth/google/auth/transport/__init__.py b/packages/google-auth/google/auth/transport/__init__.py new file mode 100644 index 000000000000..4575763500b2 --- /dev/null +++ b/packages/google-auth/google/auth/transport/__init__.py @@ -0,0 +1,104 @@ +# Copyright 2016 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. + +"""Transport - HTTP client library support. + +:mod:`google.auth` is designed to work with various HTTP client libraries such +as urllib3 and requests. In order to work across these libraries with different +interfaces some abstraction is needed. + +This module provides two interfaces that are implemented by transport adapters +to support HTTP libraries. :class:`Request` defines the interface expected by +:mod:`google.auth` to make requests. :class:`Response` defines the interface +for the return value of :class:`Request`. +""" + +import abc +import http.client as http_client + +DEFAULT_RETRYABLE_STATUS_CODES = ( + http_client.INTERNAL_SERVER_ERROR, + http_client.SERVICE_UNAVAILABLE, + http_client.GATEWAY_TIMEOUT, + http_client.REQUEST_TIMEOUT, + http_client.TOO_MANY_REQUESTS, +) +"""Sequence[int]: HTTP status codes indicating a request can be retried. +""" + + +DEFAULT_REFRESH_STATUS_CODES = (http_client.UNAUTHORIZED,) +"""Sequence[int]: Which HTTP status code indicate that credentials should be +refreshed. +""" + +DEFAULT_MAX_REFRESH_ATTEMPTS = 2 +"""int: How many times to refresh the credentials and retry a request.""" + + +class Response(metaclass=abc.ABCMeta): + """HTTP Response data.""" + + @abc.abstractproperty + def status(self): + """int: The HTTP status code.""" + raise NotImplementedError("status must be implemented.") + + @abc.abstractproperty + def headers(self): + """Mapping[str, str]: The HTTP response headers.""" + raise NotImplementedError("headers must be implemented.") + + @abc.abstractproperty + def data(self): + """bytes: The response body.""" + raise NotImplementedError("data must be implemented.") + + +class Request(metaclass=abc.ABCMeta): + """Interface for a callable that makes HTTP requests. + + Specific transport implementations should provide an implementation of + this that adapts their specific request / response API. + + .. automethod:: __call__ + """ + + @abc.abstractmethod + def __call__( + self, url, method="GET", body=None, headers=None, timeout=None, **kwargs + ): + """Make an HTTP request. + + Args: + url (str): The URI to be requested. + method (str): The HTTP method to use for the request. Defaults + to 'GET'. + body (bytes): The payload / body in HTTP request. + headers (Mapping[str, str]): Request headers. + timeout (Optional[int]): The number of seconds to wait for a + response from the server. If not specified or if None, the + transport-specific default timeout will be used. + kwargs: Additionally arguments passed on to the transport's + request method. + + Returns: + Response: The HTTP response. + + Raises: + google.auth.exceptions.TransportError: If any exception occurred. + """ + # pylint: disable=redundant-returns-doc, missing-raises-doc + # (pylint doesn't play well with abstract docstrings.) + raise NotImplementedError("__call__ must be implemented.") diff --git a/packages/google-auth/google/auth/transport/_aiohttp_requests.py b/packages/google-auth/google/auth/transport/_aiohttp_requests.py new file mode 100644 index 000000000000..2465b6a9703a --- /dev/null +++ b/packages/google-auth/google/auth/transport/_aiohttp_requests.py @@ -0,0 +1,396 @@ +# 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. + +"""Transport adapter for Async HTTP (aiohttp). + +NOTE: This async support is experimental and marked internal. This surface may +change in minor releases. +""" + +from __future__ import absolute_import + +import asyncio +import functools +import logging + +import aiohttp # type: ignore +import urllib3 # type: ignore + +from google.auth import _helpers +from google.auth import exceptions +from google.auth import transport +from google.auth.aio import _helpers as _helpers_async +from google.auth.transport import requests + + +_LOGGER = logging.getLogger(__name__) + +# Timeout can be re-defined depending on async requirement. Currently made 60s more than +# sync timeout. +_DEFAULT_TIMEOUT = 180 # in seconds + + +class _CombinedResponse(transport.Response): + """ + In order to more closely resemble the `requests` interface, where a raw + and deflated content could be accessed at once, this class lazily reads the + stream in `transport.Response` so both return forms can be used. + + The gzip and deflate transfer-encodings are automatically decoded for you + because the default parameter for autodecompress into the ClientSession is set + to False, and therefore we add this class to act as a wrapper for a user to be + able to access both the raw and decoded response bodies - mirroring the sync + implementation. + """ + + def __init__(self, response): + self._response = response + self._raw_content = None + + def _is_compressed(self): + headers = self._response.headers + return "Content-Encoding" in headers and ( + headers["Content-Encoding"] == "gzip" + or headers["Content-Encoding"] == "deflate" + ) + + @property + def status(self): + return self._response.status + + @property + def headers(self): + return self._response.headers + + @property + def data(self): + return self._response.content + + async def raw_content(self): + if self._raw_content is None: + self._raw_content = await self._response.content.read() + return self._raw_content + + async def content(self): + # Load raw_content if necessary + await self.raw_content() + if self._is_compressed(): + decoder = urllib3.response.MultiDecoder( + self._response.headers["Content-Encoding"] + ) + decompressed = decoder.decompress(self._raw_content) + return decompressed + + return self._raw_content + + +class _Response(transport.Response): + """ + Requests transport response adapter. + + Args: + response (requests.Response): The raw Requests response. + """ + + def __init__(self, response): + self._response = response + + @property + def status(self): + return self._response.status + + @property + def headers(self): + return self._response.headers + + @property + def data(self): + return self._response.content + + +class Request(transport.Request): + """Requests request adapter. + + This class is used internally for making requests using asyncio transports + in a consistent way. If you use :class:`AuthorizedSession` you do not need + to construct or use this class directly. + + This class can be useful if you want to manually refresh a + :class:`~google.auth.credentials.Credentials` instance:: + + import google.auth.transport.aiohttp_requests + + request = google.auth.transport.aiohttp_requests.Request() + + credentials.refresh(request) + + Args: + session (aiohttp.ClientSession): An instance :class:`aiohttp.ClientSession` used + to make HTTP requests. If not specified, a session will be created. + + .. automethod:: __call__ + """ + + def __init__(self, session=None): + # TODO: Use auto_decompress property for aiohttp 3.7+ + if session is not None and session._auto_decompress: + raise exceptions.InvalidOperation( + "Client sessions with auto_decompress=True are not supported." + ) + self.session = session + + async def __call__( + self, + url, + method="GET", + body=None, + headers=None, + timeout=_DEFAULT_TIMEOUT, + **kwargs, + ): + """ + Make an HTTP request using aiohttp. + + Args: + url (str): The URL to be requested. + method (Optional[str]): + The HTTP method to use for the request. Defaults to 'GET'. + body (Optional[bytes]): + The payload or body in HTTP request. + headers (Optional[Mapping[str, str]]): + Request headers. + timeout (Optional[int]): The number of seconds to wait for a + response from the server. If not specified or if None, the + requests default timeout will be used. + kwargs: Additional arguments passed through to the underlying + requests :meth:`requests.Session.request` method. + + Returns: + google.auth.transport.Response: The HTTP response. + + Raises: + google.auth.exceptions.TransportError: If any exception occurred. + """ + + try: + if self.session is None: # pragma: NO COVER + self.session = aiohttp.ClientSession( + auto_decompress=False + ) # pragma: NO COVER + _helpers.request_log(_LOGGER, method, url, body, headers) + response = await self.session.request( + method, url, data=body, headers=headers, timeout=timeout, **kwargs + ) + await _helpers_async.response_log_async(_LOGGER, response) + return _CombinedResponse(response) + + except aiohttp.ClientError as caught_exc: + new_exc = exceptions.TransportError(caught_exc) + raise new_exc from caught_exc + + except asyncio.TimeoutError as caught_exc: + new_exc = exceptions.TransportError(caught_exc) + raise new_exc from caught_exc + + +class AuthorizedSession(aiohttp.ClientSession): + """This is an async implementation of the Authorized Session class. We utilize an + aiohttp transport instance, and the interface mirrors the google.auth.transport.requests + Authorized Session class, except for the change in the transport used in the async use case. + + A Requests Session class with credentials. + + This class is used to perform requests to API endpoints that require + authorization:: + + from google.auth.transport import aiohttp_requests + + async with aiohttp_requests.AuthorizedSession(credentials) as authed_session: + response = await authed_session.request( + 'GET', 'https://www.googleapis.com/storage/v1/b') + + The underlying :meth:`request` implementation handles adding the + credentials' headers to the request and refreshing credentials as needed. + + Args: + credentials (google.auth._credentials_async.Credentials): + The credentials to add to the request. + refresh_status_codes (Sequence[int]): Which HTTP status codes indicate + that credentials should be refreshed and the request should be + retried. + max_refresh_attempts (int): The maximum number of times to attempt to + refresh the credentials and retry the request. + refresh_timeout (Optional[int]): The timeout value in seconds for + credential refresh HTTP requests. + auth_request (google.auth.transport.aiohttp_requests.Request): + (Optional) An instance of + :class:`~google.auth.transport.aiohttp_requests.Request` used when + refreshing credentials. If not passed, + an instance of :class:`~google.auth.transport.aiohttp_requests.Request` + is created. + kwargs: Additional arguments passed through to the underlying + ClientSession :meth:`aiohttp.ClientSession` object. + """ + + def __init__( + self, + credentials, + refresh_status_codes=transport.DEFAULT_REFRESH_STATUS_CODES, + max_refresh_attempts=transport.DEFAULT_MAX_REFRESH_ATTEMPTS, + refresh_timeout=None, + auth_request=None, + auto_decompress=False, + **kwargs, + ): + super(AuthorizedSession, self).__init__(**kwargs) + self.credentials = credentials + self._refresh_status_codes = refresh_status_codes + self._max_refresh_attempts = max_refresh_attempts + self._refresh_timeout = refresh_timeout + self._is_mtls = False + self._auth_request = auth_request + self._auth_request_session = None + self._loop = asyncio.get_event_loop() + self._refresh_lock = asyncio.Lock() + self._auto_decompress = auto_decompress + + async def request( + self, + method, + url, + data=None, + headers=None, + max_allowed_time=None, + timeout=_DEFAULT_TIMEOUT, + auto_decompress=False, + **kwargs, + ): + """Implementation of Authorized Session aiohttp request. + + Args: + method (str): + The http request method used (e.g. GET, PUT, DELETE) + url (str): + The url at which the http request is sent. + data (Optional[dict]): Dictionary, list of tuples, bytes, or file-like + object to send in the body of the Request. + headers (Optional[dict]): Dictionary of HTTP Headers to send with the + Request. + timeout (Optional[Union[float, aiohttp.ClientTimeout]]): + The amount of time in seconds to wait for the server response + with each individual request. Can also be passed as an + ``aiohttp.ClientTimeout`` object. + max_allowed_time (Optional[float]): + If the method runs longer than this, a ``Timeout`` exception is + automatically raised. Unlike the ``timeout`` parameter, this + value applies to the total method execution time, even if + multiple requests are made under the hood. + + Mind that it is not guaranteed that the timeout error is raised + at ``max_allowed_time``. It might take longer, for example, if + an underlying request takes a lot of time, but the request + itself does not timeout, e.g. if a large file is being + transmitted. The timout error will be raised after such + request completes. + """ + # Headers come in as bytes which isn't expected behavior, the resumable + # media libraries in some cases expect a str type for the header values, + # but sometimes the operations return these in bytes types. + if headers: + for key in headers.keys(): + if type(headers[key]) is bytes: + headers[key] = headers[key].decode("utf-8") + + async with aiohttp.ClientSession( + auto_decompress=self._auto_decompress, + trust_env=kwargs.get("trust_env", False), + ) as self._auth_request_session: + auth_request = Request(self._auth_request_session) + self._auth_request = auth_request + + # Use a kwarg for this instead of an attribute to maintain + # thread-safety. + _credential_refresh_attempt = kwargs.pop("_credential_refresh_attempt", 0) + # Make a copy of the headers. They will be modified by the credentials + # and we want to pass the original headers if we recurse. + request_headers = headers.copy() if headers is not None else {} + + # Do not apply the timeout unconditionally in order to not override the + # _auth_request's default timeout. + auth_request = ( + self._auth_request + if timeout is None + else functools.partial(self._auth_request, timeout=timeout) + ) + + remaining_time = max_allowed_time + + with requests.TimeoutGuard(remaining_time, asyncio.TimeoutError) as guard: + await self.credentials.before_request( + auth_request, method, url, request_headers + ) + + with requests.TimeoutGuard(remaining_time, asyncio.TimeoutError) as guard: + response = await super(AuthorizedSession, self).request( + method, + url, + data=data, + headers=request_headers, + timeout=timeout, + **kwargs, + ) + + remaining_time = guard.remaining_timeout + + if ( + response.status in self._refresh_status_codes + and _credential_refresh_attempt < self._max_refresh_attempts + ): + requests._LOGGER.info( + "Refreshing credentials due to a %s response. Attempt %s/%s.", + response.status, + _credential_refresh_attempt + 1, + self._max_refresh_attempts, + ) + + # Do not apply the timeout unconditionally in order to not override the + # _auth_request's default timeout. + auth_request = ( + self._auth_request + if timeout is None + else functools.partial(self._auth_request, timeout=timeout) + ) + + with requests.TimeoutGuard( + remaining_time, asyncio.TimeoutError + ) as guard: + async with self._refresh_lock: + await self._loop.run_in_executor( + None, self.credentials.refresh, auth_request + ) + + remaining_time = guard.remaining_timeout + + return await self.request( + method, + url, + data=data, + headers=headers, + max_allowed_time=remaining_time, + timeout=timeout, + _credential_refresh_attempt=_credential_refresh_attempt + 1, + **kwargs, + ) + + return response diff --git a/packages/google-auth/google/auth/transport/_custom_tls_signer.py b/packages/google-auth/google/auth/transport/_custom_tls_signer.py new file mode 100644 index 000000000000..9279158d45c6 --- /dev/null +++ b/packages/google-auth/google/auth/transport/_custom_tls_signer.py @@ -0,0 +1,283 @@ +# 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. + +""" +Code for configuring client side TLS to offload the signing operation to +signing libraries. +""" + +import ctypes +import json +import logging +import os +import sys + +import cffi # type: ignore + +from google.auth import exceptions + +_LOGGER = logging.getLogger(__name__) + +# C++ offload lib requires google-auth lib to provide the following callback: +# using SignFunc = int (*)(unsigned char *sig, size_t *sig_len, +# const unsigned char *tbs, size_t tbs_len) +# The bytes to be signed and the length are provided via `tbs` and `tbs_len`, +# the callback computes the signature, and write the signature and its length +# into `sig` and `sig_len`. +# If the signing is successful, the callback returns 1, otherwise it returns 0. +SIGN_CALLBACK_CTYPE = ctypes.CFUNCTYPE( + ctypes.c_int, # return type + ctypes.POINTER(ctypes.c_ubyte), # sig + ctypes.POINTER(ctypes.c_size_t), # sig_len + ctypes.POINTER(ctypes.c_ubyte), # tbs + ctypes.c_size_t, # tbs_len +) + + +# Cast SSL_CTX* to void* +def _cast_ssl_ctx_to_void_p_pyopenssl(ssl_ctx): + return ctypes.cast(int(cffi.FFI().cast("intptr_t", ssl_ctx)), ctypes.c_void_p) + + +# Cast SSL_CTX* to void* +def _cast_ssl_ctx_to_void_p_stdlib(context): + return ctypes.c_void_p.from_address( + id(context) + ctypes.sizeof(ctypes.c_void_p) * 2 + ) + + +# Load offload library and set up the function types. +def load_offload_lib(offload_lib_path): + _LOGGER.debug("loading offload library from %s", offload_lib_path) + + # winmode parameter is only available for python 3.8+. + lib = ( + ctypes.CDLL(offload_lib_path, winmode=0) + if sys.version_info >= (3, 8) and os.name == "nt" + else ctypes.CDLL(offload_lib_path) + ) + + # Set up types for: + # int ConfigureSslContext(SignFunc sign_func, const char *cert, SSL_CTX *ctx) + lib.ConfigureSslContext.argtypes = [ + SIGN_CALLBACK_CTYPE, + ctypes.c_char_p, + ctypes.c_void_p, + ] + lib.ConfigureSslContext.restype = ctypes.c_int + + return lib + + +# Load signer library and set up the function types. +# See: https://github.com/googleapis/enterprise-certificate-proxy/blob/main/cshared/main.go +def load_signer_lib(signer_lib_path): + _LOGGER.debug("loading signer library from %s", signer_lib_path) + + # winmode parameter is only available for python 3.8+. + lib = ( + ctypes.CDLL(signer_lib_path, winmode=0) + if sys.version_info >= (3, 8) and os.name == "nt" + else ctypes.CDLL(signer_lib_path) + ) + + # Set up types for: + # func GetCertPemForPython(configFilePath *C.char, certHolder *byte, certHolderLen int) + lib.GetCertPemForPython.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int] + # Returns: certLen + lib.GetCertPemForPython.restype = ctypes.c_int + + # Set up types for: + # func SignForPython(configFilePath *C.char, digest *byte, digestLen int, + # sigHolder *byte, sigHolderLen int) + lib.SignForPython.argtypes = [ + ctypes.c_char_p, + ctypes.c_char_p, + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_int, + ] + # Returns: the signature length + lib.SignForPython.restype = ctypes.c_int + + return lib + + +def load_provider_lib(provider_lib_path): + _LOGGER.debug("loading provider library from %s", provider_lib_path) + + # winmode parameter is only available for python 3.8+. + lib = ( + ctypes.CDLL(provider_lib_path, winmode=0) + if sys.version_info >= (3, 8) and os.name == "nt" + else ctypes.CDLL(provider_lib_path) + ) + + lib.ECP_attach_to_ctx.argtypes = [ctypes.c_void_p, ctypes.c_char_p] + lib.ECP_attach_to_ctx.restype = ctypes.c_int + + return lib + + +# Computes SHA256 hash. +def _compute_sha256_digest(to_be_signed, to_be_signed_len): + from cryptography.hazmat.primitives import hashes + + data = ctypes.string_at(to_be_signed, to_be_signed_len) + hash = hashes.Hash(hashes.SHA256()) + hash.update(data) + return hash.finalize() + + +# Create the signing callback. The actual signing work is done by the +# `SignForPython` method from the signer lib. +def get_sign_callback(signer_lib, config_file_path): + def sign_callback(sig, sig_len, tbs, tbs_len): + _LOGGER.debug("calling sign callback...") + + digest = _compute_sha256_digest(tbs, tbs_len) + digestArray = ctypes.c_char * len(digest) + + # reserve 2000 bytes for the signature, shoud be more then enough. + # RSA signature is 256 bytes, EC signature is 70~72. + sig_holder_len = 2000 + sig_holder = ctypes.create_string_buffer(sig_holder_len) + + signature_len = signer_lib.SignForPython( + config_file_path.encode(), # configFilePath + digestArray.from_buffer(bytearray(digest)), # digest + len(digest), # digestLen + sig_holder, # sigHolder + sig_holder_len, # sigHolderLen + ) + + if signature_len == 0: + # signing failed, return 0 + return 0 + + sig_len[0] = signature_len + bs = bytearray(sig_holder) + for i in range(signature_len): + sig[i] = bs[i] + + return 1 + + return SIGN_CALLBACK_CTYPE(sign_callback) + + +# Obtain the certificate bytes by calling the `GetCertPemForPython` method from +# the signer lib. The method is called twice, the first time is to compute the +# cert length, then we create a buffer to hold the cert, and call it again to +# fill the buffer. +def get_cert(signer_lib, config_file_path): + # First call to calculate the cert length + cert_len = signer_lib.GetCertPemForPython( + config_file_path.encode(), # configFilePath + None, # certHolder + 0, # certHolderLen + ) + if cert_len == 0: + raise exceptions.MutualTLSChannelError("failed to get certificate") + + # Then we create an array to hold the cert, and call again to fill the cert + cert_holder = ctypes.create_string_buffer(cert_len) + signer_lib.GetCertPemForPython( + config_file_path.encode(), # configFilePath + cert_holder, # certHolder + cert_len, # certHolderLen + ) + return bytes(cert_holder) + + +class CustomTlsSigner(object): + def __init__(self, enterprise_cert_file_path): + """ + This class loads the offload and signer library, and calls APIs from + these libraries to obtain the cert and a signing callback, and attach + them to SSL context. The cert and the signing callback will be used + for client authentication in TLS handshake. + + Args: + enterprise_cert_file_path (str): the path to a enterprise cert JSON + file. The file should contain the following field: + + { + "libs": { + "ecp_client": "...", + "tls_offload": "..." + } + } + """ + self._enterprise_cert_file_path = enterprise_cert_file_path + self._cert = None + self._sign_callback = None + self._provider_lib = None + + def load_libraries(self): + with open(self._enterprise_cert_file_path, "r") as f: + enterprise_cert_json = json.load(f) + libs = enterprise_cert_json.get("libs", {}) + + signer_library = libs.get("ecp_client", None) + offload_library = libs.get("tls_offload", None) + provider_library = libs.get("ecp_provider", None) + + # Using newer provider implementation. This is mutually exclusive to the + # offload implementation. + if provider_library: + self._provider_lib = load_provider_lib(provider_library) + return + + # Using old offload implementation + if offload_library and signer_library: + self._offload_lib = load_offload_lib(offload_library) + self._signer_lib = load_signer_lib(signer_library) + self.set_up_custom_key() + return + + raise exceptions.MutualTLSChannelError("enterprise cert file is invalid") + + def set_up_custom_key(self): + # We need to keep a reference of the cert and sign callback so it won't + # be garbage collected, otherwise it will crash when used by signer lib. + self._cert = get_cert(self._signer_lib, self._enterprise_cert_file_path) + self._sign_callback = get_sign_callback( + self._signer_lib, self._enterprise_cert_file_path + ) + + def should_use_provider(self): + if self._provider_lib: + return True + return False + + def attach_to_ssl_context(self, ctx): + if self.should_use_provider(): + if not self._provider_lib.ECP_attach_to_ctx( + _cast_ssl_ctx_to_void_p_stdlib(ctx), + self._enterprise_cert_file_path.encode("ascii"), + ): + raise exceptions.MutualTLSChannelError( + "failed to configure ECP Provider SSL context" + ) + elif self._offload_lib and self._signer_lib: + if not self._offload_lib.ConfigureSslContext( + self._sign_callback, + ctypes.c_char_p(self._cert), + _cast_ssl_ctx_to_void_p_pyopenssl(ctx._ctx._context), + ): + raise exceptions.MutualTLSChannelError( + "failed to configure ECP Offload SSL context" + ) + else: + raise exceptions.MutualTLSChannelError("Invalid ECP configuration.") diff --git a/packages/google-auth/google/auth/transport/_http_client.py b/packages/google-auth/google/auth/transport/_http_client.py new file mode 100644 index 000000000000..ecd51bdf8a75 --- /dev/null +++ b/packages/google-auth/google/auth/transport/_http_client.py @@ -0,0 +1,114 @@ +# Copyright 2016 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. + +"""Transport adapter for http.client, for internal use only.""" + +import http.client as http_client +import logging +import socket +import urllib + +from google.auth import _helpers +from google.auth import exceptions +from google.auth import transport + +_LOGGER = logging.getLogger(__name__) + + +class Response(transport.Response): + """http.client transport response adapter. + + Args: + response (http.client.HTTPResponse): The raw http client response. + """ + + def __init__(self, response): + self._status = response.status + self._headers = {key.lower(): value for key, value in response.getheaders()} + self._data = response.read() + + @property + def status(self): + return self._status + + @property + def headers(self): + return self._headers + + @property + def data(self): + return self._data + + +class Request(transport.Request): + """http.client transport request adapter.""" + + def __call__( + self, url, method="GET", body=None, headers=None, timeout=None, **kwargs + ): + """Make an HTTP request using http.client. + + Args: + url (str): The URI to be requested. + method (str): The HTTP method to use for the request. Defaults + to 'GET'. + body (bytes): The payload / body in HTTP request. + headers (Mapping): Request headers. + timeout (Optional(int)): The number of seconds to wait for a + response from the server. If not specified or if None, the + socket global default timeout will be used. + kwargs: Additional arguments passed throught to the underlying + :meth:`~http.client.HTTPConnection.request` method. + + Returns: + Response: The HTTP response. + + Raises: + google.auth.exceptions.TransportError: If any exception occurred. + """ + # socket._GLOBAL_DEFAULT_TIMEOUT is the default in http.client. + if timeout is None: + timeout = socket._GLOBAL_DEFAULT_TIMEOUT + + # http.client doesn't allow None as the headers argument. + if headers is None: + headers = {} + + # http.client needs the host and path parts specified separately. + parts = urllib.parse.urlsplit(url) + path = urllib.parse.urlunsplit( + ("", "", parts.path, parts.query, parts.fragment) + ) + + if parts.scheme != "http": + raise exceptions.TransportError( + "http.client transport only supports the http scheme, {}" + "was specified".format(parts.scheme) + ) + + connection = http_client.HTTPConnection(parts.netloc, timeout=timeout) + + try: + _helpers.request_log(_LOGGER, method, url, body, headers) + connection.request(method, path, body=body, headers=headers, **kwargs) + response = connection.getresponse() + _helpers.response_log(_LOGGER, response) + return Response(response) + + except (http_client.HTTPException, socket.error) as caught_exc: + new_exc = exceptions.TransportError(caught_exc) + raise new_exc from caught_exc + + finally: + connection.close() diff --git a/packages/google-auth/google/auth/transport/_mtls_helper.py b/packages/google-auth/google/auth/transport/_mtls_helper.py new file mode 100644 index 000000000000..7740f2fe8152 --- /dev/null +++ b/packages/google-auth/google/auth/transport/_mtls_helper.py @@ -0,0 +1,446 @@ +# 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. + +"""Helper functions for getting mTLS cert and key.""" + +import json +import logging +from os import environ, getenv, path +import re +import subprocess + +from google.auth import exceptions + +CONTEXT_AWARE_METADATA_PATH = "~/.secureConnect/context_aware_metadata.json" +CERTIFICATE_CONFIGURATION_DEFAULT_PATH = "~/.config/gcloud/certificate_config.json" +_CERTIFICATE_CONFIGURATION_ENV = "GOOGLE_API_CERTIFICATE_CONFIG" +_CERT_PROVIDER_COMMAND = "cert_provider_command" +_CERT_REGEX = re.compile( + b"-----BEGIN CERTIFICATE-----.+-----END CERTIFICATE-----\r?\n?", re.DOTALL +) + +# support various format of key files, e.g. +# "-----BEGIN PRIVATE KEY-----...", +# "-----BEGIN EC PRIVATE KEY-----...", +# "-----BEGIN RSA PRIVATE KEY-----..." +# "-----BEGIN ENCRYPTED PRIVATE KEY-----" +_KEY_REGEX = re.compile( + b"-----BEGIN [A-Z ]*PRIVATE KEY-----.+-----END [A-Z ]*PRIVATE KEY-----\r?\n?", + re.DOTALL, +) + +_LOGGER = logging.getLogger(__name__) + + +_PASSPHRASE_REGEX = re.compile( + b"-----BEGIN PASSPHRASE-----(.+)-----END PASSPHRASE-----", re.DOTALL +) + + +def _check_config_path(config_path): + """Checks for config file path. If it exists, returns the absolute path with user expansion; + otherwise returns None. + + Args: + config_path (str): The config file path for either context_aware_metadata.json or certificate_config.json for example + + Returns: + str: absolute path if exists and None otherwise. + """ + config_path = path.expanduser(config_path) + if not path.exists(config_path): + _LOGGER.debug("%s is not found.", config_path) + return None + return config_path + + +def _load_json_file(path): + """Reads and loads JSON from the given path. Used to read both X509 workload certificate and + secure connect configurations. + + Args: + path (str): the path to read from. + + Returns: + Dict[str, str]: The JSON stored at the file. + + Raises: + google.auth.exceptions.ClientCertError: If failed to parse the file as JSON. + """ + try: + with open(path) as f: + json_data = json.load(f) + except ValueError as caught_exc: + new_exc = exceptions.ClientCertError(caught_exc) + raise new_exc from caught_exc + + return json_data + + +def _get_workload_cert_and_key(certificate_config_path=None): + """Read the workload identity cert and key files specified in the certificate config provided. + If no config path is provided, check the environment variable: "GOOGLE_API_CERTIFICATE_CONFIG" + first, then the well known gcloud location: "~/.config/gcloud/certificate_config.json". + + Args: + certificate_config_path (string): The certificate config path. If no path is provided, + the environment variable will be checked first, then the well known gcloud location. + + Returns: + Tuple[Optional[bytes], Optional[bytes]]: client certificate bytes in PEM format and key + bytes in PEM format. + + Raises: + google.auth.exceptions.ClientCertError: if problems occurs when retrieving + the certificate or key information. + """ + + cert_path, key_path = _get_workload_cert_and_key_paths(certificate_config_path) + + if cert_path is None and key_path is None: + return None, None + + return _read_cert_and_key_files(cert_path, key_path) + + +def _get_cert_config_path(certificate_config_path=None): + """Get the certificate configuration path based on the following order: + + 1: Explicit override, if set + 2: Environment variable, if set + 3: Well-known location + + Returns "None" if the selected config file does not exist. + + Args: + certificate_config_path (string): The certificate config path. If provided, the well known + location and environment variable will be ignored. + + Returns: + The absolute path of the certificate config file, and None if the file does not exist. + """ + + if certificate_config_path is None: + env_path = environ.get(_CERTIFICATE_CONFIGURATION_ENV, None) + if env_path is not None and env_path != "": + certificate_config_path = env_path + else: + certificate_config_path = CERTIFICATE_CONFIGURATION_DEFAULT_PATH + + certificate_config_path = path.expanduser(certificate_config_path) + if not path.exists(certificate_config_path): + return None + return certificate_config_path + + +def _get_workload_cert_and_key_paths(config_path): + absolute_path = _get_cert_config_path(config_path) + if absolute_path is None: + return None, None + + data = _load_json_file(absolute_path) + + if "cert_configs" not in data: + raise exceptions.ClientCertError( + 'Certificate config file {} is in an invalid format, a "cert configs" object is expected'.format( + absolute_path + ) + ) + cert_configs = data["cert_configs"] + + if "workload" not in cert_configs: + raise exceptions.ClientCertError( + 'Certificate config file {} is in an invalid format, a "workload" cert config is expected'.format( + absolute_path + ) + ) + workload = cert_configs["workload"] + + if "cert_path" not in workload: + raise exceptions.ClientCertError( + 'Certificate config file {} is in an invalid format, a "cert_path" is expected in the workload cert config'.format( + absolute_path + ) + ) + cert_path = workload["cert_path"] + + if "key_path" not in workload: + raise exceptions.ClientCertError( + 'Certificate config file {} is in an invalid format, a "key_path" is expected in the workload cert config'.format( + absolute_path + ) + ) + key_path = workload["key_path"] + + return cert_path, key_path + + +def _read_cert_and_key_files(cert_path, key_path): + cert_data = _read_cert_file(cert_path) + key_data = _read_key_file(key_path) + + return cert_data, key_data + + +def _read_cert_file(cert_path): + with open(cert_path, "rb") as cert_file: + cert_data = cert_file.read() + + cert_match = re.findall(_CERT_REGEX, cert_data) + if len(cert_match) != 1: + raise exceptions.ClientCertError( + "Certificate file {} is in an invalid format, a single PEM formatted certificate is expected".format( + cert_path + ) + ) + return cert_match[0] + + +def _read_key_file(key_path): + with open(key_path, "rb") as key_file: + key_data = key_file.read() + + key_match = re.findall(_KEY_REGEX, key_data) + if len(key_match) != 1: + raise exceptions.ClientCertError( + "Private key file {} is in an invalid format, a single PEM formatted private key is expected".format( + key_path + ) + ) + + return key_match[0] + + +def _run_cert_provider_command(command, expect_encrypted_key=False): + """Run the provided command, and return client side mTLS cert, key and + passphrase. + + Args: + command (List[str]): cert provider command. + expect_encrypted_key (bool): If encrypted private key is expected. + + Returns: + Tuple[bytes, bytes, bytes]: client certificate bytes in PEM format, key + bytes in PEM format and passphrase bytes. + + Raises: + google.auth.exceptions.ClientCertError: if problems occurs when running + the cert provider command or generating cert, key and passphrase. + """ + try: + process = subprocess.Popen( + command, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + stdout, stderr = process.communicate() + except OSError as caught_exc: + new_exc = exceptions.ClientCertError(caught_exc) + raise new_exc from caught_exc + + # Check cert provider command execution error. + if process.returncode != 0: + raise exceptions.ClientCertError( + "Cert provider command returns non-zero status code %s" % process.returncode + ) + + # Extract certificate (chain), key and passphrase. + cert_match = re.findall(_CERT_REGEX, stdout) + if len(cert_match) != 1: + raise exceptions.ClientCertError("Client SSL certificate is missing or invalid") + key_match = re.findall(_KEY_REGEX, stdout) + if len(key_match) != 1: + raise exceptions.ClientCertError("Client SSL key is missing or invalid") + passphrase_match = re.findall(_PASSPHRASE_REGEX, stdout) + + if expect_encrypted_key: + if len(passphrase_match) != 1: + raise exceptions.ClientCertError("Passphrase is missing or invalid") + if b"ENCRYPTED" not in key_match[0]: + raise exceptions.ClientCertError("Encrypted private key is expected") + return cert_match[0], key_match[0], passphrase_match[0].strip() + + if b"ENCRYPTED" in key_match[0]: + raise exceptions.ClientCertError("Encrypted private key is not expected") + if len(passphrase_match) > 0: + raise exceptions.ClientCertError("Passphrase is not expected") + return cert_match[0], key_match[0], None + + +def get_client_ssl_credentials( + generate_encrypted_key=False, + context_aware_metadata_path=CONTEXT_AWARE_METADATA_PATH, + certificate_config_path=CERTIFICATE_CONFIGURATION_DEFAULT_PATH, +): + """Returns the client side certificate, private key and passphrase. + + We look for certificates and keys with the following order of priority: + 1. Certificate and key specified by certificate_config.json. + Currently, only X.509 workload certificates are supported. + 2. Certificate and key specified by context aware metadata (i.e. SecureConnect). + + Args: + generate_encrypted_key (bool): If set to True, encrypted private key + and passphrase will be generated; otherwise, unencrypted private key + will be generated and passphrase will be None. This option only + affects keys obtained via context_aware_metadata.json. + context_aware_metadata_path (str): The context_aware_metadata.json file path. + certificate_config_path (str): The certificate_config.json file path. + + Returns: + Tuple[bool, bytes, bytes, bytes]: + A boolean indicating if cert, key and passphrase are obtained, the + cert bytes and key bytes both in PEM format, and passphrase bytes. + + Raises: + google.auth.exceptions.ClientCertError: if problems occurs when getting + the cert, key and passphrase. + """ + + # 1. Check for certificate config json. + cert_config_path = _check_config_path(certificate_config_path) + if cert_config_path: + # Attempt to retrieve X.509 Workload cert and key. + cert, key = _get_workload_cert_and_key(cert_config_path) + if cert and key: + return True, cert, key, None + + # 2. Check for context aware metadata json + metadata_path = _check_config_path(context_aware_metadata_path) + + if metadata_path: + metadata_json = _load_json_file(metadata_path) + + if _CERT_PROVIDER_COMMAND not in metadata_json: + raise exceptions.ClientCertError("Cert provider command is not found") + + command = metadata_json[_CERT_PROVIDER_COMMAND] + + if generate_encrypted_key and "--with_passphrase" not in command: + command.append("--with_passphrase") + + # Execute the command. + cert, key, passphrase = _run_cert_provider_command( + command, expect_encrypted_key=generate_encrypted_key + ) + return True, cert, key, passphrase + + return False, None, None, None + + +def get_client_cert_and_key(client_cert_callback=None): + """Returns the client side certificate and private key. The function first + tries to get certificate and key from client_cert_callback; if the callback + is None or doesn't provide certificate and key, the function tries application + default SSL credentials. + + Args: + client_cert_callback (Optional[Callable[[], (bytes, bytes)]]): An + optional callback which returns client certificate bytes and private + key bytes both in PEM format. + + Returns: + Tuple[bool, bytes, bytes]: + A boolean indicating if cert and key are obtained, the cert bytes + and key bytes both in PEM format. + + Raises: + google.auth.exceptions.ClientCertError: if problems occurs when getting + the cert and key. + """ + if client_cert_callback: + cert, key = client_cert_callback() + return True, cert, key + + has_cert, cert, key, _ = get_client_ssl_credentials(generate_encrypted_key=False) + return has_cert, cert, key + + +def decrypt_private_key(key, passphrase): + """A helper function to decrypt the private key with the given passphrase. + google-auth library doesn't support passphrase protected private key for + mutual TLS channel. This helper function can be used to decrypt the + passphrase protected private key in order to estalish mutual TLS channel. + + For example, if you have a function which produces client cert, passphrase + protected private key and passphrase, you can convert it to a client cert + callback function accepted by google-auth:: + + from google.auth.transport import _mtls_helper + + def your_client_cert_function(): + return cert, encrypted_key, passphrase + + # callback accepted by google-auth for mutual TLS channel. + def client_cert_callback(): + cert, encrypted_key, passphrase = your_client_cert_function() + decrypted_key = _mtls_helper.decrypt_private_key(encrypted_key, + passphrase) + return cert, decrypted_key + + Args: + key (bytes): The private key bytes in PEM format. + passphrase (bytes): The passphrase bytes. + + Returns: + bytes: The decrypted private key in PEM format. + + Raises: + ImportError: If pyOpenSSL is not installed. + OpenSSL.crypto.Error: If there is any problem decrypting the private key. + """ + from OpenSSL import crypto + + # First convert encrypted_key_bytes to PKey object + pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, key, passphrase=passphrase) + + # Then dump the decrypted key bytes + return crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey) + + +def check_use_client_cert(): + """Returns boolean for whether the client certificate should be used for mTLS. + + If GOOGLE_API_USE_CLIENT_CERTIFICATE is set to true or false, a corresponding + bool value will be returned. If the value is set to an unexpected string, it + will default to False. + If GOOGLE_API_USE_CLIENT_CERTIFICATE is unset, the value will be inferred + by reading a file pointed at by GOOGLE_API_CERTIFICATE_CONFIG, and verifying + it contains a "workload" section. If so, the function will return True, + otherwise False. + + Returns: + bool: Whether the client certificate should be used for mTLS connection. + """ + use_client_cert = getenv("GOOGLE_API_USE_CLIENT_CERTIFICATE") + # Check if the value of GOOGLE_API_USE_CLIENT_CERTIFICATE is set. + if use_client_cert: + return use_client_cert.lower() == "true" + else: + # Check if the value of GOOGLE_API_CERTIFICATE_CONFIG is set. + cert_path = getenv("GOOGLE_API_CERTIFICATE_CONFIG") + if cert_path: + try: + with open(cert_path, "r") as f: + content = json.load(f) + # verify json has workload key + content["cert_configs"]["workload"] + return True + except ( + FileNotFoundError, + OSError, + KeyError, + TypeError, + json.JSONDecodeError, + ) as e: + _LOGGER.debug("error decoding certificate: %s", e) + return False diff --git a/packages/google-auth/google/auth/transport/_requests_base.py b/packages/google-auth/google/auth/transport/_requests_base.py new file mode 100644 index 000000000000..0608223d8c20 --- /dev/null +++ b/packages/google-auth/google/auth/transport/_requests_base.py @@ -0,0 +1,53 @@ +# Copyright 2024 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. + +"""Transport adapter for Base Requests.""" +# NOTE: The coverage for this file is temporarily disabled in `.coveragerc` +# since it is currently unused. + +import abc + + +_DEFAULT_TIMEOUT = 120 # in second + + +class _BaseAuthorizedSession(metaclass=abc.ABCMeta): + """Base class for a Request Session with credentials. This class is intended to capture + the common logic between synchronous and asynchronous request sessions and is not intended to + be instantiated directly. + + Args: + credentials (google.auth._credentials_base.BaseCredentials): The credentials to + add to the request. + """ + + def __init__(self, credentials): + self.credentials = credentials + + @abc.abstractmethod + def request( + self, + method, + url, + data=None, + headers=None, + max_allowed_time=None, + timeout=_DEFAULT_TIMEOUT, + **kwargs + ): + raise NotImplementedError("Request must be implemented") + + @abc.abstractmethod + def close(self): + raise NotImplementedError("Close must be implemented") diff --git a/packages/google-auth/google/auth/transport/grpc.py b/packages/google-auth/google/auth/transport/grpc.py new file mode 100644 index 000000000000..d9185e7aa4f1 --- /dev/null +++ b/packages/google-auth/google/auth/transport/grpc.py @@ -0,0 +1,337 @@ +# Copyright 2016 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. + +"""Authorization support for gRPC.""" + +from __future__ import absolute_import + +import logging + +from google.auth import exceptions +from google.auth.transport import _mtls_helper +from google.oauth2 import service_account + +try: + import grpc # type: ignore +except ImportError as caught_exc: # pragma: NO COVER + raise ImportError( + "gRPC is not installed from please install the grpcio package to use the gRPC transport." + ) from caught_exc + +_LOGGER = logging.getLogger(__name__) + + +class AuthMetadataPlugin(grpc.AuthMetadataPlugin): + """A `gRPC AuthMetadataPlugin`_ that inserts the credentials into each + request. + + .. _gRPC AuthMetadataPlugin: + http://www.grpc.io/grpc/python/grpc.html#grpc.AuthMetadataPlugin + + Args: + credentials (google.auth.credentials.Credentials): The credentials to + add to requests. + request (google.auth.transport.Request): A HTTP transport request + object used to refresh credentials as needed. + default_host (Optional[str]): A host like "pubsub.googleapis.com". + This is used when a self-signed JWT is created from service + account credentials. + """ + + def __init__(self, credentials, request, default_host=None): + # pylint: disable=no-value-for-parameter + # pylint doesn't realize that the super method takes no arguments + # because this class is the same name as the superclass. + super(AuthMetadataPlugin, self).__init__() + self._credentials = credentials + self._request = request + self._default_host = default_host + + def _get_authorization_headers(self, context): + """Gets the authorization headers for a request. + + Returns: + Sequence[Tuple[str, str]]: A list of request headers (key, value) + to add to the request. + """ + headers = {} + + # https://google.aip.dev/auth/4111 + # Attempt to use self-signed JWTs when a service account is used. + # A default host must be explicitly provided since it cannot always + # be determined from the context.service_url. + if isinstance(self._credentials, service_account.Credentials): + self._credentials._create_self_signed_jwt( + "https://{}/".format(self._default_host) if self._default_host else None + ) + + self._credentials.before_request( + self._request, context.method_name, context.service_url, headers + ) + + return list(headers.items()) + + def __call__(self, context, callback): + """Passes authorization metadata into the given callback. + + Args: + context (grpc.AuthMetadataContext): The RPC context. + callback (grpc.AuthMetadataPluginCallback): The callback that will + be invoked to pass in the authorization metadata. + """ + callback(self._get_authorization_headers(context), None) + + +def secure_authorized_channel( + credentials, + request, + target, + ssl_credentials=None, + client_cert_callback=None, + **kwargs +): + """Creates a secure authorized gRPC channel. + + This creates a channel with SSL and :class:`AuthMetadataPlugin`. This + channel can be used to create a stub that can make authorized requests. + Users can configure client certificate or rely on device certificates to + establish a mutual TLS channel, if the `GOOGLE_API_USE_CLIENT_CERTIFICATE` + variable is explicitly set to `true`. + + Example:: + + import google.auth + import google.auth.transport.grpc + import google.auth.transport.requests + from google.cloud.speech.v1 import cloud_speech_pb2 + + # Get credentials. + credentials, _ = google.auth.default() + + # Get an HTTP request function to refresh credentials. + request = google.auth.transport.requests.Request() + + # Create a channel. + channel = google.auth.transport.grpc.secure_authorized_channel( + credentials, regular_endpoint, request, + ssl_credentials=grpc.ssl_channel_credentials()) + + # Use the channel to create a stub. + cloud_speech.create_Speech_stub(channel) + + Usage: + + There are actually a couple of options to create a channel, depending on if + you want to create a regular or mutual TLS channel. + + First let's list the endpoints (regular vs mutual TLS) to choose from:: + + regular_endpoint = 'speech.googleapis.com:443' + mtls_endpoint = 'speech.mtls.googleapis.com:443' + + Option 1: create a regular (non-mutual) TLS channel by explicitly setting + the ssl_credentials:: + + regular_ssl_credentials = grpc.ssl_channel_credentials() + + channel = google.auth.transport.grpc.secure_authorized_channel( + credentials, regular_endpoint, request, + ssl_credentials=regular_ssl_credentials) + + Option 2: create a mutual TLS channel by calling a callback which returns + the client side certificate and the key (Note that + `GOOGLE_API_USE_CLIENT_CERTIFICATE` environment variable must be explicitly + set to `true`):: + + def my_client_cert_callback(): + code_to_load_client_cert_and_key() + if loaded: + return (pem_cert_bytes, pem_key_bytes) + raise MyClientCertFailureException() + + try: + channel = google.auth.transport.grpc.secure_authorized_channel( + credentials, mtls_endpoint, request, + client_cert_callback=my_client_cert_callback) + except MyClientCertFailureException: + # handle the exception + + Option 3: use application default SSL credentials. It searches and uses + the command in a context aware metadata file, which is available on devices + with endpoint verification support (Note that + `GOOGLE_API_USE_CLIENT_CERTIFICATE` environment variable must be explicitly + set to `true`). + See https://cloud.google.com/endpoint-verification/docs/overview:: + + try: + default_ssl_credentials = SslCredentials() + except: + # Exception can be raised if the context aware metadata is malformed. + # See :class:`SslCredentials` for the possible exceptions. + + # Choose the endpoint based on the SSL credentials type. + if default_ssl_credentials.is_mtls: + endpoint_to_use = mtls_endpoint + else: + endpoint_to_use = regular_endpoint + channel = google.auth.transport.grpc.secure_authorized_channel( + credentials, endpoint_to_use, request, + ssl_credentials=default_ssl_credentials) + + Option 4: not setting ssl_credentials and client_cert_callback. For devices + without endpoint verification support or `GOOGLE_API_USE_CLIENT_CERTIFICATE` + environment variable is not `true`, a regular TLS channel is created; + otherwise, a mutual TLS channel is created, however, the call should be + wrapped in a try/except block in case of malformed context aware metadata. + + The following code uses regular_endpoint, it works the same no matter the + created channle is regular or mutual TLS. Regular endpoint ignores client + certificate and key:: + + channel = google.auth.transport.grpc.secure_authorized_channel( + credentials, regular_endpoint, request) + + The following code uses mtls_endpoint, if the created channle is regular, + and API mtls_endpoint is confgured to require client SSL credentials, API + calls using this channel will be rejected:: + + channel = google.auth.transport.grpc.secure_authorized_channel( + credentials, mtls_endpoint, request) + + Args: + credentials (google.auth.credentials.Credentials): The credentials to + add to requests. + request (google.auth.transport.Request): A HTTP transport request + object used to refresh credentials as needed. Even though gRPC + is a separate transport, there's no way to refresh the credentials + without using a standard http transport. + target (str): The host and port of the service. + ssl_credentials (grpc.ChannelCredentials): Optional SSL channel + credentials. This can be used to specify different certificates. + This argument is mutually exclusive with client_cert_callback; + providing both will raise an exception. + If ssl_credentials and client_cert_callback are None, application + default SSL credentials are used if `GOOGLE_API_USE_CLIENT_CERTIFICATE` + environment variable is explicitly set to `true`, otherwise one way TLS + SSL credentials are used. + client_cert_callback (Callable[[], (bytes, bytes)]): Optional + callback function to obtain client certicate and key for mutual TLS + connection. This argument is mutually exclusive with + ssl_credentials; providing both will raise an exception. + This argument does nothing unless `GOOGLE_API_USE_CLIENT_CERTIFICATE` + environment variable is explicitly set to `true`. + kwargs: Additional arguments to pass to :func:`grpc.secure_channel`. + + Returns: + grpc.Channel: The created gRPC channel. + + Raises: + google.auth.exceptions.MutualTLSChannelError: If mutual TLS channel + creation failed for any reason. + """ + # Create the metadata plugin for inserting the authorization header. + metadata_plugin = AuthMetadataPlugin(credentials, request) + + # Create a set of grpc.CallCredentials using the metadata plugin. + google_auth_credentials = grpc.metadata_call_credentials(metadata_plugin) + + if ssl_credentials and client_cert_callback: + raise exceptions.MalformedError( + "Received both ssl_credentials and client_cert_callback; " + "these are mutually exclusive." + ) + + # If SSL credentials are not explicitly set, try client_cert_callback and ADC. + if not ssl_credentials: + use_client_cert = _mtls_helper.check_use_client_cert() + if use_client_cert and client_cert_callback: + # Use the callback if provided. + cert, key = client_cert_callback() + ssl_credentials = grpc.ssl_channel_credentials( + certificate_chain=cert, private_key=key + ) + elif use_client_cert: + # Use application default SSL credentials. + adc_ssl_credentils = SslCredentials() + ssl_credentials = adc_ssl_credentils.ssl_credentials + else: + ssl_credentials = grpc.ssl_channel_credentials() + + # Combine the ssl credentials and the authorization credentials. + composite_credentials = grpc.composite_channel_credentials( + ssl_credentials, google_auth_credentials + ) + + return grpc.secure_channel(target, composite_credentials, **kwargs) + + +class SslCredentials: + """Class for application default SSL credentials. + + The behavior is controlled by `GOOGLE_API_USE_CLIENT_CERTIFICATE` environment + variable whose default value is `false`. Client certificate will not be used + unless the environment variable is explicitly set to `true`. See + https://google.aip.dev/auth/4114 + + If the environment variable is `true`, then for devices with endpoint verification + support, a device certificate will be automatically loaded and mutual TLS will + be established. + See https://cloud.google.com/endpoint-verification/docs/overview. + """ + + def __init__(self): + use_client_cert = _mtls_helper.check_use_client_cert() + if not use_client_cert: + self._is_mtls = False + else: + # Load client SSL credentials. + metadata_path = _mtls_helper._check_config_path( + _mtls_helper.CONTEXT_AWARE_METADATA_PATH + ) + self._is_mtls = metadata_path is not None + + @property + def ssl_credentials(self): + """Get the created SSL channel credentials. + + For devices with endpoint verification support, if the device certificate + loading has any problems, corresponding exceptions will be raised. For + a device without endpoint verification support, no exceptions will be + raised. + + Returns: + grpc.ChannelCredentials: The created grpc channel credentials. + + Raises: + google.auth.exceptions.MutualTLSChannelError: If mutual TLS channel + creation failed for any reason. + """ + if self._is_mtls: + try: + _, cert, key, _ = _mtls_helper.get_client_ssl_credentials() + self._ssl_credentials = grpc.ssl_channel_credentials( + certificate_chain=cert, private_key=key + ) + except exceptions.ClientCertError as caught_exc: + new_exc = exceptions.MutualTLSChannelError(caught_exc) + raise new_exc from caught_exc + else: + self._ssl_credentials = grpc.ssl_channel_credentials() + + return self._ssl_credentials + + @property + def is_mtls(self): + """Indicates if the created SSL channel credentials is mutual TLS.""" + return self._is_mtls diff --git a/packages/google-auth/google/auth/transport/mtls.py b/packages/google-auth/google/auth/transport/mtls.py new file mode 100644 index 000000000000..75e43687fc2f --- /dev/null +++ b/packages/google-auth/google/auth/transport/mtls.py @@ -0,0 +1,129 @@ +# 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. + +"""Utilites for mutual TLS.""" + +from google.auth import exceptions +from google.auth.transport import _mtls_helper + + +def has_default_client_cert_source(): + """Check if default client SSL credentials exists on the device. + + Returns: + bool: indicating if the default client cert source exists. + """ + if ( + _mtls_helper._check_config_path(_mtls_helper.CONTEXT_AWARE_METADATA_PATH) + is not None + ): + return True + if ( + _mtls_helper._check_config_path( + _mtls_helper.CERTIFICATE_CONFIGURATION_DEFAULT_PATH + ) + is not None + ): + return True + return False + + +def default_client_cert_source(): + """Get a callback which returns the default client SSL credentials. + + Returns: + Callable[[], [bytes, bytes]]: A callback which returns the default + client certificate bytes and private key bytes, both in PEM format. + + Raises: + google.auth.exceptions.DefaultClientCertSourceError: If the default + client SSL credentials don't exist or are malformed. + """ + if not has_default_client_cert_source(): + raise exceptions.MutualTLSChannelError( + "Default client cert source doesn't exist" + ) + + def callback(): + try: + _, cert_bytes, key_bytes = _mtls_helper.get_client_cert_and_key() + except (OSError, RuntimeError, ValueError) as caught_exc: + new_exc = exceptions.MutualTLSChannelError(caught_exc) + raise new_exc from caught_exc + + return cert_bytes, key_bytes + + return callback + + +def default_client_encrypted_cert_source(cert_path, key_path): + """Get a callback which returns the default encrpyted client SSL credentials. + + Args: + cert_path (str): The cert file path. The default client certificate will + be written to this file when the returned callback is called. + key_path (str): The key file path. The default encrypted client key will + be written to this file when the returned callback is called. + + Returns: + Callable[[], [str, str, bytes]]: A callback which generates the default + client certificate, encrpyted private key and passphrase. It writes + the certificate and private key into the cert_path and key_path, and + returns the cert_path, key_path and passphrase bytes. + + Raises: + google.auth.exceptions.DefaultClientCertSourceError: If any problem + occurs when loading or saving the client certificate and key. + """ + if not has_default_client_cert_source(): + raise exceptions.MutualTLSChannelError( + "Default client encrypted cert source doesn't exist" + ) + + def callback(): + try: + ( + _, + cert_bytes, + key_bytes, + passphrase_bytes, + ) = _mtls_helper.get_client_ssl_credentials(generate_encrypted_key=True) + with open(cert_path, "wb") as cert_file: + cert_file.write(cert_bytes) + with open(key_path, "wb") as key_file: + key_file.write(key_bytes) + except (exceptions.ClientCertError, OSError) as caught_exc: + new_exc = exceptions.MutualTLSChannelError(caught_exc) + raise new_exc from caught_exc + + return cert_path, key_path, passphrase_bytes + + return callback + + +def should_use_client_cert(): + """Returns boolean for whether the client certificate should be used for mTLS. + + This is a wrapper around _mtls_helper.check_use_client_cert(). + If GOOGLE_API_USE_CLIENT_CERTIFICATE is set to true or false, a corresponding + bool value will be returned + If GOOGLE_API_USE_CLIENT_CERTIFICATE is unset, the value will be inferred by + reading a file pointed at by GOOGLE_API_CERTIFICATE_CONFIG, and verifying it + contains a "workload" section. If so, the function will return True, + otherwise False. + + Returns: + bool: indicating whether the client certificate should be used for mTLS. + """ + return _mtls_helper.check_use_client_cert() diff --git a/packages/google-auth/google/auth/transport/requests.py b/packages/google-auth/google/auth/transport/requests.py new file mode 100644 index 000000000000..7a9612872d11 --- /dev/null +++ b/packages/google-auth/google/auth/transport/requests.py @@ -0,0 +1,596 @@ +# Copyright 2016 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. + +"""Transport adapter for Requests.""" + +from __future__ import absolute_import + +import functools +import logging +import numbers +import time + +try: + import requests +except ImportError as caught_exc: # pragma: NO COVER + raise ImportError( + "The requests library is not installed from please install the requests package to use the requests transport." + ) from caught_exc +import requests.adapters # pylint: disable=ungrouped-imports +import requests.exceptions # pylint: disable=ungrouped-imports +from requests.packages.urllib3.util.ssl_ import ( # type: ignore + create_urllib3_context, +) # pylint: disable=ungrouped-imports + +from google.auth import _helpers +from google.auth import exceptions +from google.auth import transport +import google.auth.transport._mtls_helper +from google.oauth2 import service_account + +_LOGGER = logging.getLogger(__name__) + +_DEFAULT_TIMEOUT = 120 # in seconds + + +class _Response(transport.Response): + """Requests transport response adapter. + + Args: + response (requests.Response): The raw Requests response. + """ + + def __init__(self, response): + self._response = response + + @property + def status(self): + return self._response.status_code + + @property + def headers(self): + return self._response.headers + + @property + def data(self): + return self._response.content + + +class TimeoutGuard(object): + """A context manager raising an error if the suite execution took too long. + + Args: + timeout (Union[None, Union[float, Tuple[float, float]]]): + The maximum number of seconds a suite can run without the context + manager raising a timeout exception on exit. If passed as a tuple, + the smaller of the values is taken as a timeout. If ``None``, a + timeout error is never raised. + timeout_error_type (Optional[Exception]): + The type of the error to raise on timeout. Defaults to + :class:`requests.exceptions.Timeout`. + """ + + def __init__(self, timeout, timeout_error_type=requests.exceptions.Timeout): + self._timeout = timeout + self.remaining_timeout = timeout + self._timeout_error_type = timeout_error_type + + def __enter__(self): + self._start = time.time() + return self + + def __exit__(self, exc_type, exc_value, traceback): + if exc_value: + return # let the error bubble up automatically + + if self._timeout is None: + return # nothing to do, the timeout was not specified + + elapsed = time.time() - self._start + deadline_hit = False + + if isinstance(self._timeout, numbers.Number): + self.remaining_timeout = self._timeout - elapsed + deadline_hit = self.remaining_timeout <= 0 + else: + self.remaining_timeout = tuple(x - elapsed for x in self._timeout) + deadline_hit = min(self.remaining_timeout) <= 0 + + if deadline_hit: + raise self._timeout_error_type() + + +class Request(transport.Request): + """Requests request adapter. + + This class is used internally for making requests using various transports + in a consistent way. If you use :class:`AuthorizedSession` you do not need + to construct or use this class directly. + + This class can be useful if you want to manually refresh a + :class:`~google.auth.credentials.Credentials` instance:: + + import google.auth.transport.requests + import requests + + request = google.auth.transport.requests.Request() + + credentials.refresh(request) + + Args: + session (requests.Session): An instance :class:`requests.Session` used + to make HTTP requests. If not specified, a session will be created. + + .. automethod:: __call__ + """ + + def __init__(self, session=None): + if not session: + session = requests.Session() + + self.session = session + + def __del__(self): + try: + if hasattr(self, "session") and self.session is not None: + self.session.close() + except TypeError: + # NOTE: For certain Python binary built, the queue.Empty exception + # might not be considered a normal Python exception causing + # TypeError. + pass + + def __call__( + self, + url, + method="GET", + body=None, + headers=None, + timeout=_DEFAULT_TIMEOUT, + **kwargs + ): + """Make an HTTP request using requests. + + Args: + url (str): The URI to be requested. + method (str): The HTTP method to use for the request. Defaults + to 'GET'. + body (bytes): The payload or body in HTTP request. + headers (Mapping[str, str]): Request headers. + timeout (Optional[int]): The number of seconds to wait for a + response from the server. If not specified or if None, the + requests default timeout will be used. + kwargs: Additional arguments passed through to the underlying + requests :meth:`~requests.Session.request` method. + + Returns: + google.auth.transport.Response: The HTTP response. + + Raises: + google.auth.exceptions.TransportError: If any exception occurred. + """ + try: + _helpers.request_log(_LOGGER, method, url, body, headers) + response = self.session.request( + method, url, data=body, headers=headers, timeout=timeout, **kwargs + ) + _helpers.response_log(_LOGGER, response) + return _Response(response) + except requests.exceptions.RequestException as caught_exc: + new_exc = exceptions.TransportError(caught_exc) + raise new_exc from caught_exc + + +class _MutualTlsAdapter(requests.adapters.HTTPAdapter): + """ + A TransportAdapter that enables mutual TLS. + + Args: + cert (bytes): client certificate in PEM format + key (bytes): client private key in PEM format + + Raises: + ImportError: if certifi or pyOpenSSL is not installed + OpenSSL.crypto.Error: if client cert or key is invalid + """ + + def __init__(self, cert, key): + import certifi + from OpenSSL import crypto + import urllib3.contrib.pyopenssl # type: ignore + + urllib3.contrib.pyopenssl.inject_into_urllib3() + + pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, key) + x509 = crypto.load_certificate(crypto.FILETYPE_PEM, cert) + + ctx_poolmanager = create_urllib3_context() + ctx_poolmanager.load_verify_locations(cafile=certifi.where()) + ctx_poolmanager._ctx.use_certificate(x509) + ctx_poolmanager._ctx.use_privatekey(pkey) + self._ctx_poolmanager = ctx_poolmanager + + ctx_proxymanager = create_urllib3_context() + ctx_proxymanager.load_verify_locations(cafile=certifi.where()) + ctx_proxymanager._ctx.use_certificate(x509) + ctx_proxymanager._ctx.use_privatekey(pkey) + self._ctx_proxymanager = ctx_proxymanager + + super(_MutualTlsAdapter, self).__init__() + + def init_poolmanager(self, *args, **kwargs): + kwargs["ssl_context"] = self._ctx_poolmanager + super(_MutualTlsAdapter, self).init_poolmanager(*args, **kwargs) + + def proxy_manager_for(self, *args, **kwargs): + kwargs["ssl_context"] = self._ctx_proxymanager + return super(_MutualTlsAdapter, self).proxy_manager_for(*args, **kwargs) + + +class _MutualTlsOffloadAdapter(requests.adapters.HTTPAdapter): + """ + A TransportAdapter that enables mutual TLS and offloads the client side + signing operation to the signing library. + + Args: + enterprise_cert_file_path (str): the path to a enterprise cert JSON + file. The file should contain the following field: + + { + "libs": { + "signer_library": "...", + "offload_library": "..." + } + } + + Raises: + ImportError: if certifi or pyOpenSSL is not installed + google.auth.exceptions.MutualTLSChannelError: If mutual TLS channel + creation failed for any reason. + """ + + def __init__(self, enterprise_cert_file_path): + import certifi + from google.auth.transport import _custom_tls_signer + + self.signer = _custom_tls_signer.CustomTlsSigner(enterprise_cert_file_path) + self.signer.load_libraries() + + import urllib3.contrib.pyopenssl + + urllib3.contrib.pyopenssl.inject_into_urllib3() + + poolmanager = create_urllib3_context() + poolmanager.load_verify_locations(cafile=certifi.where()) + self.signer.attach_to_ssl_context(poolmanager) + self._ctx_poolmanager = poolmanager + + proxymanager = create_urllib3_context() + proxymanager.load_verify_locations(cafile=certifi.where()) + self.signer.attach_to_ssl_context(proxymanager) + self._ctx_proxymanager = proxymanager + + super(_MutualTlsOffloadAdapter, self).__init__() + + def init_poolmanager(self, *args, **kwargs): + kwargs["ssl_context"] = self._ctx_poolmanager + super(_MutualTlsOffloadAdapter, self).init_poolmanager(*args, **kwargs) + + def proxy_manager_for(self, *args, **kwargs): + kwargs["ssl_context"] = self._ctx_proxymanager + return super(_MutualTlsOffloadAdapter, self).proxy_manager_for(*args, **kwargs) + + +class AuthorizedSession(requests.Session): + """A Requests Session class with credentials. + + This class is used to perform requests to API endpoints that require + authorization:: + + from google.auth.transport.requests import AuthorizedSession + + authed_session = AuthorizedSession(credentials) + + response = authed_session.request( + 'GET', 'https://www.googleapis.com/storage/v1/b') + + + The underlying :meth:`request` implementation handles adding the + credentials' headers to the request and refreshing credentials as needed. + + This class also supports mutual TLS via :meth:`configure_mtls_channel` + method. In order to use this method, the `GOOGLE_API_USE_CLIENT_CERTIFICATE` + environment variable must be explicitly set to ``true``, otherwise it does + nothing. Assume the environment is set to ``true``, the method behaves in the + following manner: + + If client_cert_callback is provided, client certificate and private + key are loaded using the callback; if client_cert_callback is None, + application default SSL credentials will be used. Exceptions are raised if + there are problems with the certificate, private key, or the loading process, + so it should be called within a try/except block. + + First we set the environment variable to ``true``, then create an :class:`AuthorizedSession` + instance and specify the endpoints:: + + regular_endpoint = 'https://pubsub.googleapis.com/v1/projects/{my_project_id}/topics' + mtls_endpoint = 'https://pubsub.mtls.googleapis.com/v1/projects/{my_project_id}/topics' + + authed_session = AuthorizedSession(credentials) + + Now we can pass a callback to :meth:`configure_mtls_channel`:: + + def my_cert_callback(): + # some code to load client cert bytes and private key bytes, both in + # PEM format. + some_code_to_load_client_cert_and_key() + if loaded: + return cert, key + raise MyClientCertFailureException() + + # Always call configure_mtls_channel within a try/except block. + try: + authed_session.configure_mtls_channel(my_cert_callback) + except: + # handle exceptions. + + if authed_session.is_mtls: + response = authed_session.request('GET', mtls_endpoint) + else: + response = authed_session.request('GET', regular_endpoint) + + + You can alternatively use application default SSL credentials like this:: + + try: + authed_session.configure_mtls_channel() + except: + # handle exceptions. + + Args: + credentials (google.auth.credentials.Credentials): The credentials to + add to the request. + refresh_status_codes (Sequence[int]): Which HTTP status codes indicate + that credentials should be refreshed and the request should be + retried. + max_refresh_attempts (int): The maximum number of times to attempt to + refresh the credentials and retry the request. + refresh_timeout (Optional[int]): The timeout value in seconds for + credential refresh HTTP requests. + auth_request (google.auth.transport.requests.Request): + (Optional) An instance of + :class:`~google.auth.transport.requests.Request` used when + refreshing credentials. If not passed, + an instance of :class:`~google.auth.transport.requests.Request` + is created. + default_host (Optional[str]): A host like "pubsub.googleapis.com". + This is used when a self-signed JWT is created from service + account credentials. + """ + + def __init__( + self, + credentials, + refresh_status_codes=transport.DEFAULT_REFRESH_STATUS_CODES, + max_refresh_attempts=transport.DEFAULT_MAX_REFRESH_ATTEMPTS, + refresh_timeout=None, + auth_request=None, + default_host=None, + ): + super(AuthorizedSession, self).__init__() + self.credentials = credentials + self._refresh_status_codes = refresh_status_codes + self._max_refresh_attempts = max_refresh_attempts + self._refresh_timeout = refresh_timeout + self._is_mtls = False + self._default_host = default_host + + if auth_request is None: + self._auth_request_session = requests.Session() + + # Using an adapter to make HTTP requests robust to network errors. + # This adapter retrys HTTP requests when network errors occur + # and the requests seems safely retryable. + retry_adapter = requests.adapters.HTTPAdapter(max_retries=3) + self._auth_request_session.mount("https://", retry_adapter) + + # Do not pass `self` as the session here, as it can lead to + # infinite recursion. + auth_request = Request(self._auth_request_session) + else: + self._auth_request_session = None + + # Request instance used by internal methods (for example, + # credentials.refresh). + self._auth_request = auth_request + + # https://google.aip.dev/auth/4111 + # Attempt to use self-signed JWTs when a service account is used. + if isinstance(self.credentials, service_account.Credentials): + self.credentials._create_self_signed_jwt( + "https://{}/".format(self._default_host) if self._default_host else None + ) + + def configure_mtls_channel(self, client_cert_callback=None): + """Configure the client certificate and key for SSL connection. + + The function does nothing unless `GOOGLE_API_USE_CLIENT_CERTIFICATE` is + explicitly set to `true`. In this case if client certificate and key are + successfully obtained (from the given client_cert_callback or from application + default SSL credentials), a :class:`_MutualTlsAdapter` instance will be mounted + to "https://" prefix. + + Args: + client_cert_callback (Optional[Callable[[], (bytes, bytes)]]): + The optional callback returns the client certificate and private + key bytes both in PEM format. + If the callback is None, application default SSL credentials + will be used. + + Raises: + google.auth.exceptions.MutualTLSChannelError: If mutual TLS channel + creation failed for any reason. + """ + use_client_cert = google.auth.transport._mtls_helper.check_use_client_cert() + if not use_client_cert: + self._is_mtls = False + return + try: + import OpenSSL + except ImportError as caught_exc: + new_exc = exceptions.MutualTLSChannelError(caught_exc) + raise new_exc from caught_exc + + try: + ( + self._is_mtls, + cert, + key, + ) = google.auth.transport._mtls_helper.get_client_cert_and_key( + client_cert_callback + ) + + if self._is_mtls: + mtls_adapter = _MutualTlsAdapter(cert, key) + self.mount("https://", mtls_adapter) + except ( + exceptions.ClientCertError, + ImportError, + OpenSSL.crypto.Error, + ) as caught_exc: + new_exc = exceptions.MutualTLSChannelError(caught_exc) + raise new_exc from caught_exc + + def request( + self, + method, + url, + data=None, + headers=None, + max_allowed_time=None, + timeout=_DEFAULT_TIMEOUT, + **kwargs + ): + """Implementation of Requests' request. + + Args: + timeout (Optional[Union[float, Tuple[float, float]]]): + The amount of time in seconds to wait for the server response + with each individual request. Can also be passed as a tuple + ``(connect_timeout, read_timeout)``. See :meth:`requests.Session.request` + documentation for details. + max_allowed_time (Optional[float]): + If the method runs longer than this, a ``Timeout`` exception is + automatically raised. Unlike the ``timeout`` parameter, this + value applies to the total method execution time, even if + multiple requests are made under the hood. + + Mind that it is not guaranteed that the timeout error is raised + at ``max_allowed_time``. It might take longer, for example, if + an underlying request takes a lot of time, but the request + itself does not timeout, e.g. if a large file is being + transmitted. The timout error will be raised after such + request completes. + """ + # pylint: disable=arguments-differ + # Requests has a ton of arguments to request, but only two + # (method, url) are required. We pass through all of the other + # arguments to super, so no need to exhaustively list them here. + + # Use a kwarg for this instead of an attribute to maintain + # thread-safety. + _credential_refresh_attempt = kwargs.pop("_credential_refresh_attempt", 0) + + # Make a copy of the headers. They will be modified by the credentials + # and we want to pass the original headers if we recurse. + request_headers = headers.copy() if headers is not None else {} + + # Do not apply the timeout unconditionally in order to not override the + # _auth_request's default timeout. + auth_request = ( + self._auth_request + if timeout is None + else functools.partial(self._auth_request, timeout=timeout) + ) + + remaining_time = max_allowed_time + + with TimeoutGuard(remaining_time) as guard: + self.credentials.before_request(auth_request, method, url, request_headers) + remaining_time = guard.remaining_timeout + + with TimeoutGuard(remaining_time) as guard: + _helpers.request_log(_LOGGER, method, url, data, headers) + response = super(AuthorizedSession, self).request( + method, + url, + data=data, + headers=request_headers, + timeout=timeout, + **kwargs + ) + remaining_time = guard.remaining_timeout + + # If the response indicated that the credentials needed to be + # refreshed, then refresh the credentials and re-attempt the + # request. + # A stored token may expire between the time it is retrieved and + # the time the request is made, so we may need to try twice. + if ( + response.status_code in self._refresh_status_codes + and _credential_refresh_attempt < self._max_refresh_attempts + ): + _LOGGER.info( + "Refreshing credentials due to a %s response. Attempt %s/%s.", + response.status_code, + _credential_refresh_attempt + 1, + self._max_refresh_attempts, + ) + + # Do not apply the timeout unconditionally in order to not override the + # _auth_request's default timeout. + auth_request = ( + self._auth_request + if timeout is None + else functools.partial(self._auth_request, timeout=timeout) + ) + + with TimeoutGuard(remaining_time) as guard: + self.credentials.refresh(auth_request) + remaining_time = guard.remaining_timeout + + # Recurse. Pass in the original headers, not our modified set, but + # do pass the adjusted max allowed time (i.e. the remaining total time). + return self.request( + method, + url, + data=data, + headers=headers, + max_allowed_time=remaining_time, + timeout=timeout, + _credential_refresh_attempt=_credential_refresh_attempt + 1, + **kwargs + ) + + return response + + @property + def is_mtls(self): + """Indicates if the created SSL channel is mutual TLS.""" + return self._is_mtls + + def close(self): + if self._auth_request_session is not None: + self._auth_request_session.close() + super(AuthorizedSession, self).close() diff --git a/packages/google-auth/google/auth/transport/urllib3.py b/packages/google-auth/google/auth/transport/urllib3.py new file mode 100644 index 000000000000..607a7e3b58ee --- /dev/null +++ b/packages/google-auth/google/auth/transport/urllib3.py @@ -0,0 +1,448 @@ +# Copyright 2016 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. + +"""Transport adapter for urllib3.""" + +from __future__ import absolute_import + +import logging +import warnings + +# Certifi is Mozilla's certificate bundle. Urllib3 needs a certificate bundle +# to verify HTTPS requests, and certifi is the recommended and most reliable +# way to get a root certificate bundle. See +# http://urllib3.readthedocs.io/en/latest/user-guide.html\ +# #certificate-verification +# For more details. +try: + import certifi +except ImportError: # pragma: NO COVER + certifi = None # type: ignore + +try: + import urllib3 # type: ignore + import urllib3.exceptions # type: ignore + from packaging import version # type: ignore +except ImportError as caught_exc: # pragma: NO COVER + raise ImportError( + "" + f"Error: {caught_exc}." + " The 'google-auth' library requires the extras installed " + "for urllib3 network transport." + "\n" + "Please install the necessary dependencies using pip:\n" + " pip install google-auth[urllib3]\n" + "\n" + "(Note: Using '[urllib3]' ensures the specific dependencies needed for this feature are installed. " + "We recommend running this command in your virtual environment.)" + ) from caught_exc + + +from google.auth import _helpers +from google.auth import exceptions +from google.auth import transport +from google.oauth2 import service_account + +if version.parse(urllib3.__version__) >= version.parse("2.0.0"): # pragma: NO COVER + RequestMethods = urllib3._request_methods.RequestMethods # type: ignore +else: # pragma: NO COVER + RequestMethods = urllib3.request.RequestMethods # type: ignore + +_LOGGER = logging.getLogger(__name__) + + +class _Response(transport.Response): + """urllib3 transport response adapter. + + Args: + response (urllib3.response.HTTPResponse): The raw urllib3 response. + """ + + def __init__(self, response): + self._response = response + + @property + def status(self): + return self._response.status + + @property + def headers(self): + return self._response.headers + + @property + def data(self): + return self._response.data + + +class Request(transport.Request): + """urllib3 request adapter. + + This class is used internally for making requests using various transports + in a consistent way. If you use :class:`AuthorizedHttp` you do not need + to construct or use this class directly. + + This class can be useful if you want to manually refresh a + :class:`~google.auth.credentials.Credentials` instance:: + + import google.auth.transport.urllib3 + import urllib3 + + http = urllib3.PoolManager() + request = google.auth.transport.urllib3.Request(http) + + credentials.refresh(request) + + Args: + http (urllib3.request.RequestMethods): An instance of any urllib3 + class that implements :class:`~urllib3.request.RequestMethods`, + usually :class:`urllib3.PoolManager`. + + .. automethod:: __call__ + """ + + def __init__(self, http): + self.http = http + + def __call__( + self, url, method="GET", body=None, headers=None, timeout=None, **kwargs + ): + """Make an HTTP request using urllib3. + + Args: + url (str): The URI to be requested. + method (str): The HTTP method to use for the request. Defaults + to 'GET'. + body (bytes): The payload / body in HTTP request. + headers (Mapping[str, str]): Request headers. + timeout (Optional[int]): The number of seconds to wait for a + response from the server. If not specified or if None, the + urllib3 default timeout will be used. + kwargs: Additional arguments passed throught to the underlying + urllib3 :meth:`urlopen` method. + + Returns: + google.auth.transport.Response: The HTTP response. + + Raises: + google.auth.exceptions.TransportError: If any exception occurred. + """ + # urllib3 uses a sentinel default value for timeout, so only set it if + # specified. + if timeout is not None: + kwargs["timeout"] = timeout + + try: + _helpers.request_log(_LOGGER, method, url, body, headers) + response = self.http.request( + method, url, body=body, headers=headers, **kwargs + ) + _helpers.response_log(_LOGGER, response) + return _Response(response) + except urllib3.exceptions.HTTPError as caught_exc: + new_exc = exceptions.TransportError(caught_exc) + raise new_exc from caught_exc + + +def _make_default_http(): + if certifi is not None: + return urllib3.PoolManager(cert_reqs="CERT_REQUIRED", ca_certs=certifi.where()) + else: + return urllib3.PoolManager() + + +def _make_mutual_tls_http(cert, key): + """Create a mutual TLS HTTP connection with the given client cert and key. + See https://github.com/urllib3/urllib3/issues/474#issuecomment-253168415 + + Args: + cert (bytes): client certificate in PEM format + key (bytes): client private key in PEM format + + Returns: + urllib3.PoolManager: Mutual TLS HTTP connection. + + Raises: + ImportError: If certifi or pyOpenSSL is not installed. + OpenSSL.crypto.Error: If the cert or key is invalid. + """ + import certifi + from OpenSSL import crypto + import urllib3.contrib.pyopenssl # type: ignore + + urllib3.contrib.pyopenssl.inject_into_urllib3() + ctx = urllib3.util.ssl_.create_urllib3_context() + ctx.load_verify_locations(cafile=certifi.where()) + + pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, key) + x509 = crypto.load_certificate(crypto.FILETYPE_PEM, cert) + + ctx._ctx.use_certificate(x509) + ctx._ctx.use_privatekey(pkey) + + http = urllib3.PoolManager(ssl_context=ctx) + return http + + +class AuthorizedHttp(RequestMethods): # type: ignore + """A urllib3 HTTP class with credentials. + + This class is used to perform requests to API endpoints that require + authorization:: + + from google.auth.transport.urllib3 import AuthorizedHttp + + authed_http = AuthorizedHttp(credentials) + + response = authed_http.request( + 'GET', 'https://www.googleapis.com/storage/v1/b') + + This class implements :class:`urllib3.request.RequestMethods` and can be + used just like any other :class:`urllib3.PoolManager`. + + The underlying :meth:`urlopen` implementation handles adding the + credentials' headers to the request and refreshing credentials as needed. + + This class also supports mutual TLS via :meth:`configure_mtls_channel` + method. In order to use this method, the `GOOGLE_API_USE_CLIENT_CERTIFICATE` + environment variable must be explicitly set to `true`, otherwise it does + nothing. Assume the environment is set to `true`, the method behaves in the + following manner: + If client_cert_callback is provided, client certificate and private + key are loaded using the callback; if client_cert_callback is None, + application default SSL credentials will be used. Exceptions are raised if + there are problems with the certificate, private key, or the loading process, + so it should be called within a try/except block. + + First we set the environment variable to `true`, then create an :class:`AuthorizedHttp` + instance and specify the endpoints:: + + regular_endpoint = 'https://pubsub.googleapis.com/v1/projects/{my_project_id}/topics' + mtls_endpoint = 'https://pubsub.mtls.googleapis.com/v1/projects/{my_project_id}/topics' + + authed_http = AuthorizedHttp(credentials) + + Now we can pass a callback to :meth:`configure_mtls_channel`:: + + def my_cert_callback(): + # some code to load client cert bytes and private key bytes, both in + # PEM format. + some_code_to_load_client_cert_and_key() + if loaded: + return cert, key + raise MyClientCertFailureException() + + # Always call configure_mtls_channel within a try/except block. + try: + is_mtls = authed_http.configure_mtls_channel(my_cert_callback) + except: + # handle exceptions. + + if is_mtls: + response = authed_http.request('GET', mtls_endpoint) + else: + response = authed_http.request('GET', regular_endpoint) + + You can alternatively use application default SSL credentials like this:: + + try: + is_mtls = authed_http.configure_mtls_channel() + except: + # handle exceptions. + + Args: + credentials (google.auth.credentials.Credentials): The credentials to + add to the request. + http (urllib3.PoolManager): The underlying HTTP object to + use to make requests. If not specified, a + :class:`urllib3.PoolManager` instance will be constructed with + sane defaults. + refresh_status_codes (Sequence[int]): Which HTTP status codes indicate + that credentials should be refreshed and the request should be + retried. + max_refresh_attempts (int): The maximum number of times to attempt to + refresh the credentials and retry the request. + default_host (Optional[str]): A host like "pubsub.googleapis.com". + This is used when a self-signed JWT is created from service + account credentials. + """ + + def __init__( + self, + credentials, + http=None, + refresh_status_codes=transport.DEFAULT_REFRESH_STATUS_CODES, + max_refresh_attempts=transport.DEFAULT_MAX_REFRESH_ATTEMPTS, + default_host=None, + ): + if http is None: + self.http = _make_default_http() + self._has_user_provided_http = False + else: + self.http = http + self._has_user_provided_http = True + + self.credentials = credentials + self._refresh_status_codes = refresh_status_codes + self._max_refresh_attempts = max_refresh_attempts + self._default_host = default_host + # Request instance used by internal methods (for example, + # credentials.refresh). + self._request = Request(self.http) + + # https://google.aip.dev/auth/4111 + # Attempt to use self-signed JWTs when a service account is used. + if isinstance(self.credentials, service_account.Credentials): + self.credentials._create_self_signed_jwt( + "https://{}/".format(self._default_host) if self._default_host else None + ) + + super(AuthorizedHttp, self).__init__() + + def configure_mtls_channel(self, client_cert_callback=None): + """Configures mutual TLS channel using the given client_cert_callback or + application default SSL credentials. The behavior is controlled by + `GOOGLE_API_USE_CLIENT_CERTIFICATE` environment variable. + (1) If the environment variable value is `true`, the function returns True + if the channel is mutual TLS and False otherwise. The `http` provided + in the constructor will be overwritten. + (2) If the environment variable is not set or `false`, the function does + nothing and it always return False. + + Args: + client_cert_callback (Optional[Callable[[], (bytes, bytes)]]): + The optional callback returns the client certificate and private + key bytes both in PEM format. + If the callback is None, application default SSL credentials + will be used. + + Returns: + True if the channel is mutual TLS and False otherwise. + + Raises: + google.auth.exceptions.MutualTLSChannelError: If mutual TLS channel + creation failed for any reason. + """ + use_client_cert = transport._mtls_helper.check_use_client_cert() + if not use_client_cert: + return False + try: + import OpenSSL + except ImportError as caught_exc: + new_exc = exceptions.MutualTLSChannelError(caught_exc) + raise new_exc from caught_exc + + try: + found_cert_key, cert, key = transport._mtls_helper.get_client_cert_and_key( + client_cert_callback + ) + + if found_cert_key: + self.http = _make_mutual_tls_http(cert, key) + else: + self.http = _make_default_http() + except ( + exceptions.ClientCertError, + ImportError, + OpenSSL.crypto.Error, + ) as caught_exc: + new_exc = exceptions.MutualTLSChannelError(caught_exc) + raise new_exc from caught_exc + + if self._has_user_provided_http: + self._has_user_provided_http = False + warnings.warn( + "`http` provided in the constructor is overwritten", UserWarning + ) + + return found_cert_key + + def urlopen(self, method, url, body=None, headers=None, **kwargs): + """Implementation of urllib3's urlopen.""" + # pylint: disable=arguments-differ + # We use kwargs to collect additional args that we don't need to + # introspect here. However, we do explicitly collect the two + # positional arguments. + + # Use a kwarg for this instead of an attribute to maintain + # thread-safety. + _credential_refresh_attempt = kwargs.pop("_credential_refresh_attempt", 0) + + if headers is None: + headers = self.headers + + # Make a copy of the headers. They will be modified by the credentials + # and we want to pass the original headers if we recurse. + request_headers = headers.copy() + + self.credentials.before_request(self._request, method, url, request_headers) + + response = self.http.urlopen( + method, url, body=body, headers=request_headers, **kwargs + ) + + # If the response indicated that the credentials needed to be + # refreshed, then refresh the credentials and re-attempt the + # request. + # A stored token may expire between the time it is retrieved and + # the time the request is made, so we may need to try twice. + # The reason urllib3's retries aren't used is because they + # don't allow you to modify the request headers. :/ + if ( + response.status in self._refresh_status_codes + and _credential_refresh_attempt < self._max_refresh_attempts + ): + _LOGGER.info( + "Refreshing credentials due to a %s response. Attempt %s/%s.", + response.status, + _credential_refresh_attempt + 1, + self._max_refresh_attempts, + ) + + self.credentials.refresh(self._request) + + # Recurse. Pass in the original headers, not our modified set. + return self.urlopen( + method, + url, + body=body, + headers=headers, + _credential_refresh_attempt=_credential_refresh_attempt + 1, + **kwargs, + ) + + return response + + # Proxy methods for compliance with the urllib3.PoolManager interface + + def __enter__(self): + """Proxy to ``self.http``.""" + return self.http.__enter__() + + def __exit__(self, exc_type, exc_val, exc_tb): + """Proxy to ``self.http``.""" + return self.http.__exit__(exc_type, exc_val, exc_tb) + + def __del__(self): + if hasattr(self, "http") and self.http is not None: + self.http.clear() + + @property + def headers(self): + """Proxy to ``self.http``.""" + return self.http.headers + + @headers.setter + def headers(self, value): + """Proxy to ``self.http``.""" + self.http.headers = value diff --git a/packages/google-auth/google/auth/version.py b/packages/google-auth/google/auth/version.py new file mode 100644 index 000000000000..20f2c8c0a90d --- /dev/null +++ b/packages/google-auth/google/auth/version.py @@ -0,0 +1,15 @@ +# Copyright 2021 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. + +__version__ = "2.43.0" diff --git a/packages/google-auth/google/oauth2/__init__.py b/packages/google-auth/google/oauth2/__init__.py new file mode 100644 index 000000000000..accae96579d8 --- /dev/null +++ b/packages/google-auth/google/oauth2/__init__.py @@ -0,0 +1,36 @@ +# Copyright 2016 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. + +"""Google OAuth 2.0 Library for Python.""" + +import sys +import warnings + + +class Python37DeprecationWarning(DeprecationWarning): # pragma: NO COVER + """ + Deprecation warning raised when Python 3.7 runtime is detected. + Python 3.7 support will be dropped after January 1, 2024. + """ + + pass + + +# Checks if the current runtime is Python 3.7. +if sys.version_info.major == 3 and sys.version_info.minor == 7: # pragma: NO COVER + message = ( + "After January 1, 2024, new releases of this library will drop support " + "for Python 3.7." + ) + warnings.warn(message, Python37DeprecationWarning) diff --git a/packages/google-auth/google/oauth2/_client.py b/packages/google-auth/google/oauth2/_client.py new file mode 100644 index 000000000000..b2fe982e5609 --- /dev/null +++ b/packages/google-auth/google/oauth2/_client.py @@ -0,0 +1,631 @@ +# Copyright 2016 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. + +"""OAuth 2.0 client. + +This is a client for interacting with an OAuth 2.0 authorization server's +token endpoint. + +For more information about the token endpoint, see +`Section 3.1 of rfc6749`_ + +.. _Section 3.1 of rfc6749: https://tools.ietf.org/html/rfc6749#section-3.2 +""" + +import datetime +import http.client as http_client +import json +import urllib + +from google.auth import _exponential_backoff +from google.auth import _helpers +from google.auth import credentials +from google.auth import exceptions +from google.auth import jwt +from google.auth import metrics +from google.auth import transport + +_URLENCODED_CONTENT_TYPE = "application/x-www-form-urlencoded" +_JSON_CONTENT_TYPE = "application/json" +_JWT_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer" +_REFRESH_GRANT_TYPE = "refresh_token" + + +def _handle_error_response(response_data, retryable_error): + """Translates an error response into an exception. + + Args: + response_data (Mapping | str): The decoded response data. + retryable_error Optional[bool]: A boolean indicating if an error is retryable. + Defaults to False. + + Raises: + google.auth.exceptions.RefreshError: The errors contained in response_data. + """ + + retryable_error = retryable_error if retryable_error else False + + if isinstance(response_data, str): + raise exceptions.RefreshError(response_data, retryable=retryable_error) + try: + error_details = "{}: {}".format( + response_data["error"], response_data.get("error_description") + ) + # If no details could be extracted, use the response data. + except (KeyError, ValueError): + error_details = json.dumps(response_data) + + raise exceptions.RefreshError( + error_details, response_data, retryable=retryable_error + ) + + +def _can_retry(status_code, response_data): + """Checks if a request can be retried by inspecting the status code + and response body of the request. + + Args: + status_code (int): The response status code. + response_data (Mapping | str): The decoded response data. + + Returns: + bool: True if the response is retryable. False otherwise. + """ + if status_code in transport.DEFAULT_RETRYABLE_STATUS_CODES: + return True + + try: + # For a failed response, response_body could be a string + error_desc = response_data.get("error_description") or "" + error_code = response_data.get("error") or "" + + if not isinstance(error_code, str) or not isinstance(error_desc, str): + return False + + # Per Oauth 2.0 RFC https://www.rfc-editor.org/rfc/rfc6749.html#section-4.1.2.1 + # This is needed because a redirect will not return a 500 status code. + retryable_error_descriptions = { + "internal_failure", + "server_error", + "temporarily_unavailable", + } + + if any(e in retryable_error_descriptions for e in (error_code, error_desc)): + return True + + except AttributeError: + pass + + return False + + +def _parse_expiry(response_data): + """Parses the expiry field from a response into a datetime. + + Args: + response_data (Mapping): The JSON-parsed response data. + + Returns: + Optional[datetime]: The expiration or ``None`` if no expiration was + specified. + """ + expires_in = response_data.get("expires_in", None) + + if expires_in is not None: + # Some services do not respect the OAUTH2.0 RFC and send expires_in as a + # JSON String. + if isinstance(expires_in, str): + expires_in = int(expires_in) + + return _helpers.utcnow() + datetime.timedelta(seconds=expires_in) + else: + return None + + +def _token_endpoint_request_no_throw( + request, + token_uri, + body, + access_token=None, + use_json=False, + can_retry=True, + headers=None, + **kwargs +): + """Makes a request to the OAuth 2.0 authorization server's token endpoint. + This function doesn't throw on response errors. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + token_uri (str): The OAuth 2.0 authorizations server's token endpoint + URI. + body (Mapping[str, str]): The parameters to send in the request body. + access_token (Optional(str)): The access token needed to make the request. + use_json (Optional(bool)): Use urlencoded format or json format for the + content type. The default value is False. + can_retry (bool): Enable or disable request retry behavior. + headers (Optional[Mapping[str, str]]): The headers for the request. + kwargs: Additional arguments passed on to the request method. The + kwargs will be passed to `requests.request` method, see: + https://docs.python-requests.org/en/latest/api/#requests.request. + For example, you can use `cert=("cert_pem_path", "key_pem_path")` + to set up client side SSL certificate, and use + `verify="ca_bundle_path"` to set up the CA certificates for sever + side SSL certificate verification. + + Returns: + Tuple(bool, Mapping[str, str], Optional[bool]): A boolean indicating + if the request is successful, a mapping for the JSON-decoded response + data and in the case of an error a boolean indicating if the error + is retryable. + """ + if use_json: + headers_to_use = {"Content-Type": _JSON_CONTENT_TYPE} + body = json.dumps(body).encode("utf-8") + else: + headers_to_use = {"Content-Type": _URLENCODED_CONTENT_TYPE} + body = urllib.parse.urlencode(body).encode("utf-8") + + if access_token: + headers_to_use["Authorization"] = "Bearer {}".format(access_token) + + if headers: + headers_to_use.update(headers) + + response_data = {} + retryable_error = False + + retries = _exponential_backoff.ExponentialBackoff() + for _ in retries: + response = request( + method="POST", url=token_uri, headers=headers_to_use, body=body, **kwargs + ) + response_body = ( + response.data.decode("utf-8") + if hasattr(response.data, "decode") + else response.data + ) + + try: + # response_body should be a JSON + response_data = json.loads(response_body) + except ValueError: + response_data = response_body + + if response.status == http_client.OK: + return True, response_data, None + + retryable_error = _can_retry( + status_code=response.status, response_data=response_data + ) + + if not can_retry or not retryable_error: + return False, response_data, retryable_error + + return False, response_data, retryable_error + + +def _token_endpoint_request( + request, + token_uri, + body, + access_token=None, + use_json=False, + can_retry=True, + headers=None, + **kwargs +): + """Makes a request to the OAuth 2.0 authorization server's token endpoint. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + token_uri (str): The OAuth 2.0 authorizations server's token endpoint + URI. + body (Mapping[str, str]): The parameters to send in the request body. + access_token (Optional(str)): The access token needed to make the request. + use_json (Optional(bool)): Use urlencoded format or json format for the + content type. The default value is False. + can_retry (bool): Enable or disable request retry behavior. + headers (Optional[Mapping[str, str]]): The headers for the request. + kwargs: Additional arguments passed on to the request method. The + kwargs will be passed to `requests.request` method, see: + https://docs.python-requests.org/en/latest/api/#requests.request. + For example, you can use `cert=("cert_pem_path", "key_pem_path")` + to set up client side SSL certificate, and use + `verify="ca_bundle_path"` to set up the CA certificates for sever + side SSL certificate verification. + + Returns: + Mapping[str, str]: The JSON-decoded response data. + + Raises: + google.auth.exceptions.RefreshError: If the token endpoint returned + an error. + """ + + ( + response_status_ok, + response_data, + retryable_error, + ) = _token_endpoint_request_no_throw( + request, + token_uri, + body, + access_token=access_token, + use_json=use_json, + can_retry=can_retry, + headers=headers, + **kwargs + ) + if not response_status_ok: + _handle_error_response(response_data, retryable_error) + return response_data + + +def jwt_grant(request, token_uri, assertion, can_retry=True): + """Implements the JWT Profile for OAuth 2.0 Authorization Grants. + + For more details, see `rfc7523 section 4`_. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + token_uri (str): The OAuth 2.0 authorizations server's token endpoint + URI. + assertion (str): The OAuth 2.0 assertion. + can_retry (bool): Enable or disable request retry behavior. + + Returns: + Tuple[str, Optional[datetime], Mapping[str, str]]: The access token, + expiration, and additional data returned by the token endpoint. + + Raises: + google.auth.exceptions.RefreshError: If the token endpoint returned + an error. + + .. _rfc7523 section 4: https://tools.ietf.org/html/rfc7523#section-4 + """ + body = {"assertion": assertion, "grant_type": _JWT_GRANT_TYPE} + + response_data = _token_endpoint_request( + request, + token_uri, + body, + can_retry=can_retry, + headers={ + metrics.API_CLIENT_HEADER: metrics.token_request_access_token_sa_assertion() + }, + ) + + try: + access_token = response_data["access_token"] + except KeyError as caught_exc: + new_exc = exceptions.RefreshError( + "No access token in response.", response_data, retryable=False + ) + raise new_exc from caught_exc + + expiry = _parse_expiry(response_data) + + return access_token, expiry, response_data + + +def call_iam_generate_id_token_endpoint( + request, + iam_id_token_endpoint, + signer_email, + audience, + access_token, + universe_domain=credentials.DEFAULT_UNIVERSE_DOMAIN, +): + """Call iam.generateIdToken endpoint to get ID token. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + iam_id_token_endpoint (str): The IAM ID token endpoint to use. + 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. + universe_domain (str): The universe domain for the request. The + default is ``googleapis.com``. + + Returns: + Tuple[str, datetime]: The ID token and expiration. + """ + body = {"audience": audience, "includeEmail": "true", "useEmailAzp": "true"} + + response_data = _token_endpoint_request( + request, + iam_id_token_endpoint.replace( + credentials.DEFAULT_UNIVERSE_DOMAIN, universe_domain + ).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 + ) + raise new_exc from 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. + + This is a variant on the standard JWT Profile that is currently unique + to Google. This was added for the benefit of authenticating to services + that require ID Tokens instead of access tokens or JWT bearer tokens. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + token_uri (str): The OAuth 2.0 authorization server's token endpoint + URI. + assertion (str): JWT token signed by a service account. The token's + payload must include a ``target_audience`` claim. + can_retry (bool): Enable or disable request retry behavior. + + Returns: + Tuple[str, Optional[datetime], Mapping[str, str]]: + The (encoded) Open ID Connect ID Token, expiration, and additional + data returned by the endpoint. + + Raises: + google.auth.exceptions.RefreshError: If the token endpoint returned + an error. + """ + body = {"assertion": assertion, "grant_type": _JWT_GRANT_TYPE} + + response_data = _token_endpoint_request( + request, + token_uri, + body, + can_retry=can_retry, + headers={ + metrics.API_CLIENT_HEADER: metrics.token_request_id_token_sa_assertion() + }, + ) + + try: + id_token = response_data["id_token"] + except KeyError as caught_exc: + new_exc = exceptions.RefreshError( + "No ID token in response.", response_data, retryable=False + ) + raise new_exc from caught_exc + + payload = jwt.decode(id_token, verify=False) + expiry = datetime.datetime.utcfromtimestamp(payload["exp"]) + + return id_token, expiry, response_data + + +def _handle_refresh_grant_response(response_data, refresh_token): + """Extract tokens from refresh grant response. + + Args: + response_data (Mapping[str, str]): Refresh grant response data. + refresh_token (str): Current refresh token. + + Returns: + Tuple[str, str, Optional[datetime], Mapping[str, str]]: The access token, + refresh token, expiration, and additional data returned by the token + endpoint. If response_data doesn't have refresh token, then the current + refresh token will be returned. + + Raises: + google.auth.exceptions.RefreshError: If the token endpoint returned + an error. + """ + try: + access_token = response_data["access_token"] + except KeyError as caught_exc: + new_exc = exceptions.RefreshError( + "No access token in response.", response_data, retryable=False + ) + raise new_exc from caught_exc + + refresh_token = response_data.get("refresh_token", refresh_token) + expiry = _parse_expiry(response_data) + + return access_token, refresh_token, expiry, response_data + + +def refresh_grant( + request, + token_uri, + refresh_token, + client_id, + client_secret, + scopes=None, + rapt_token=None, + can_retry=True, +): + """Implements the OAuth 2.0 refresh token grant. + + For more details, see `rfc678 section 6`_. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + token_uri (str): The OAuth 2.0 authorizations server's token endpoint + URI. + refresh_token (str): The refresh token to use to get a new access + token. + client_id (str): The OAuth 2.0 application's client ID. + client_secret (str): The Oauth 2.0 appliaction's client secret. + scopes (Optional(Sequence[str])): Scopes to request. If present, all + scopes must be authorized for the refresh token. Useful if refresh + token has a wild card scope (e.g. + 'https://www.googleapis.com/auth/any-api'). + rapt_token (Optional(str)): The reauth Proof Token. + can_retry (bool): Enable or disable request retry behavior. + + Returns: + Tuple[str, str, Optional[datetime], Mapping[str, str]]: The access + token, new or current refresh token, expiration, and additional data + returned by the token endpoint. + + Raises: + google.auth.exceptions.RefreshError: If the token endpoint returned + an error. + + .. _rfc6748 section 6: https://tools.ietf.org/html/rfc6749#section-6 + """ + body = { + "grant_type": _REFRESH_GRANT_TYPE, + "client_id": client_id, + "client_secret": client_secret, + "refresh_token": refresh_token, + } + if scopes: + body["scope"] = " ".join(scopes) + if rapt_token: + body["rapt"] = rapt_token + + response_data = _token_endpoint_request( + request, token_uri, body, can_retry=can_retry + ) + return _handle_refresh_grant_response(response_data, refresh_token) + + +def _lookup_trust_boundary(request, url, headers=None): + """Implements the global lookup of a credential trust boundary. + For the lookup, we send a request to the global lookup endpoint and then + parse the response. Service account credentials, workload identity + pools and workforce pools implementation may have trust boundaries configured. + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + url (str): The trust boundary lookup url. + headers (Optional[Mapping[str, str]]): The headers for the request. + Returns: + Mapping[str,list|str]: A dictionary containing + "locations" as a list of allowed locations as strings and + "encodedLocations" as a hex string. + e.g: + { + "locations": [ + "us-central1", "us-east1", "europe-west1", "asia-east1" + ], + "encodedLocations": "0xA30" + } + If the credential is not set up with explicit trust boundaries, a trust boundary + of "all" will be returned as a default response. + { + "locations": [], + "encodedLocations": "0x0" + } + Raises: + exceptions.RefreshError: If the response status code is not 200. + exceptions.MalformedError: If the response is not in a valid format. + """ + + response_data = _lookup_trust_boundary_request(request, url, headers=headers) + # In case of no-op response, the "locations" list may or may not be present as an empty list. + if "encodedLocations" not in response_data: + raise exceptions.MalformedError( + "Invalid trust boundary info: {}".format(response_data) + ) + return response_data + + +def _lookup_trust_boundary_request(request, url, can_retry=True, headers=None): + """Makes a request to the trust boundary lookup endpoint. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + url (str): The trust boundary lookup url. + can_retry (bool): Enable or disable request retry behavior. Defaults to true. + headers (Optional[Mapping[str, str]]): The headers for the request. + + Returns: + Mapping[str, str]: The JSON-decoded response data. + + Raises: + google.auth.exceptions.RefreshError: If the token endpoint returned + an error. + """ + ( + response_status_ok, + response_data, + retryable_error, + ) = _lookup_trust_boundary_request_no_throw(request, url, can_retry, headers) + if not response_status_ok: + _handle_error_response(response_data, retryable_error) + return response_data + + +def _lookup_trust_boundary_request_no_throw(request, url, can_retry=True, headers=None): + """Makes a request to the trust boundary lookup endpoint. This + function doesn't throw on response errors. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + url (str): The trust boundary lookup url. + can_retry (bool): Enable or disable request retry behavior. Defaults to true. + headers (Optional[Mapping[str, str]]): The headers for the request. + + Returns: + Tuple(bool, Mapping[str, str], Optional[bool]): A boolean indicating + if the request is successful, a mapping for the JSON-decoded response + data and in the case of an error a boolean indicating if the error + is retryable. + """ + + response_data = {} + retryable_error = False + + retries = _exponential_backoff.ExponentialBackoff() + for _ in retries: + response = request(method="GET", url=url, headers=headers) + response_body = ( + response.data.decode("utf-8") + if hasattr(response.data, "decode") + else response.data + ) + + try: + # response_body should be a JSON + response_data = json.loads(response_body) + except ValueError: + response_data = response_body + + if response.status == http_client.OK: + return True, response_data, None + + retryable_error = _can_retry( + status_code=response.status, response_data=response_data + ) + + if not can_retry or not retryable_error: + return False, response_data, retryable_error + + return False, response_data, retryable_error diff --git a/packages/google-auth/google/oauth2/_client_async.py b/packages/google-auth/google/oauth2/_client_async.py new file mode 100644 index 000000000000..768f8eac2a05 --- /dev/null +++ b/packages/google-auth/google/oauth2/_client_async.py @@ -0,0 +1,290 @@ +# 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. + +"""OAuth 2.0 async client. + +This is a client for interacting with an OAuth 2.0 authorization server's +token endpoint. + +For more information about the token endpoint, see +`Section 3.1 of rfc6749`_ + +.. _Section 3.1 of rfc6749: https://tools.ietf.org/html/rfc6749#section-3.2 +""" + +import datetime +import http.client as http_client +import json +import urllib + +from google.auth import _exponential_backoff +from google.auth import exceptions +from google.auth import jwt +from google.oauth2 import _client as client + + +async def _token_endpoint_request_no_throw( + request, token_uri, body, access_token=None, use_json=False, can_retry=True +): + """Makes a request to the OAuth 2.0 authorization server's token endpoint. + This function doesn't throw on response errors. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + token_uri (str): The OAuth 2.0 authorizations server's token endpoint + URI. + body (Mapping[str, str]): The parameters to send in the request body. + access_token (Optional(str)): The access token needed to make the request. + use_json (Optional(bool)): Use urlencoded format or json format for the + content type. The default value is False. + can_retry (bool): Enable or disable request retry behavior. + + Returns: + Tuple(bool, Mapping[str, str], Optional[bool]): A boolean indicating + if the request is successful, a mapping for the JSON-decoded response + data and in the case of an error a boolean indicating if the error + is retryable. + """ + if use_json: + headers = {"Content-Type": client._JSON_CONTENT_TYPE} + body = json.dumps(body).encode("utf-8") + else: + headers = {"Content-Type": client._URLENCODED_CONTENT_TYPE} + body = urllib.parse.urlencode(body).encode("utf-8") + + if access_token: + headers["Authorization"] = "Bearer {}".format(access_token) + + response_data = {} + retryable_error = False + + retries = _exponential_backoff.ExponentialBackoff() + for _ in retries: + response = await request( + method="POST", url=token_uri, headers=headers, body=body + ) + + # Using data.read() resulted in zlib decompression errors. This may require future investigation. + response_body1 = await response.content() + + response_body = ( + response_body1.decode("utf-8") + if hasattr(response_body1, "decode") + else response_body1 + ) + + try: + response_data = json.loads(response_body) + except ValueError: + response_data = response_body + + if response.status == http_client.OK: + return True, response_data, None + + retryable_error = client._can_retry( + status_code=response.status, response_data=response_data + ) + + if not can_retry or not retryable_error: + return False, response_data, retryable_error + + return False, response_data, retryable_error + + +async def _token_endpoint_request( + request, token_uri, body, access_token=None, use_json=False, can_retry=True +): + """Makes a request to the OAuth 2.0 authorization server's token endpoint. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + token_uri (str): The OAuth 2.0 authorizations server's token endpoint + URI. + body (Mapping[str, str]): The parameters to send in the request body. + access_token (Optional(str)): The access token needed to make the request. + use_json (Optional(bool)): Use urlencoded format or json format for the + content type. The default value is False. + can_retry (bool): Enable or disable request retry behavior. + + Returns: + Mapping[str, str]: The JSON-decoded response data. + + Raises: + google.auth.exceptions.RefreshError: If the token endpoint returned + an error. + """ + + ( + response_status_ok, + response_data, + retryable_error, + ) = await _token_endpoint_request_no_throw( + request, + token_uri, + body, + access_token=access_token, + use_json=use_json, + can_retry=can_retry, + ) + if not response_status_ok: + client._handle_error_response(response_data, retryable_error) + return response_data + + +async def jwt_grant(request, token_uri, assertion, can_retry=True): + """Implements the JWT Profile for OAuth 2.0 Authorization Grants. + + For more details, see `rfc7523 section 4`_. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + token_uri (str): The OAuth 2.0 authorizations server's token endpoint + URI. + assertion (str): The OAuth 2.0 assertion. + can_retry (bool): Enable or disable request retry behavior. + + Returns: + Tuple[str, Optional[datetime], Mapping[str, str]]: The access token, + expiration, and additional data returned by the token endpoint. + + Raises: + google.auth.exceptions.RefreshError: If the token endpoint returned + an error. + + .. _rfc7523 section 4: https://tools.ietf.org/html/rfc7523#section-4 + """ + body = {"assertion": assertion, "grant_type": client._JWT_GRANT_TYPE} + + response_data = await _token_endpoint_request( + request, token_uri, body, can_retry=can_retry + ) + + try: + access_token = response_data["access_token"] + except KeyError as caught_exc: + new_exc = exceptions.RefreshError( + "No access token in response.", response_data, retryable=False + ) + raise new_exc from caught_exc + + expiry = client._parse_expiry(response_data) + + return access_token, expiry, response_data + + +async 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. + + This is a variant on the standard JWT Profile that is currently unique + to Google. This was added for the benefit of authenticating to services + that require ID Tokens instead of access tokens or JWT bearer tokens. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + token_uri (str): The OAuth 2.0 authorization server's token endpoint + URI. + assertion (str): JWT token signed by a service account. The token's + payload must include a ``target_audience`` claim. + can_retry (bool): Enable or disable request retry behavior. + + Returns: + Tuple[str, Optional[datetime], Mapping[str, str]]: + The (encoded) Open ID Connect ID Token, expiration, and additional + data returned by the endpoint. + + Raises: + google.auth.exceptions.RefreshError: If the token endpoint returned + an error. + """ + body = {"assertion": assertion, "grant_type": client._JWT_GRANT_TYPE} + + response_data = await _token_endpoint_request( + request, token_uri, body, can_retry=can_retry + ) + + try: + id_token = response_data["id_token"] + except KeyError as caught_exc: + new_exc = exceptions.RefreshError( + "No ID token in response.", response_data, retryable=False + ) + raise new_exc from caught_exc + + payload = jwt.decode(id_token, verify=False) + expiry = datetime.datetime.utcfromtimestamp(payload["exp"]) + + return id_token, expiry, response_data + + +async def refresh_grant( + request, + token_uri, + refresh_token, + client_id, + client_secret, + scopes=None, + rapt_token=None, + can_retry=True, +): + """Implements the OAuth 2.0 refresh token grant. + + For more details, see `rfc678 section 6`_. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + token_uri (str): The OAuth 2.0 authorizations server's token endpoint + URI. + refresh_token (str): The refresh token to use to get a new access + token. + client_id (str): The OAuth 2.0 application's client ID. + client_secret (str): The Oauth 2.0 appliaction's client secret. + scopes (Optional(Sequence[str])): Scopes to request. If present, all + scopes must be authorized for the refresh token. Useful if refresh + token has a wild card scope (e.g. + 'https://www.googleapis.com/auth/any-api'). + rapt_token (Optional(str)): The reauth Proof Token. + can_retry (bool): Enable or disable request retry behavior. + + Returns: + Tuple[str, Optional[str], Optional[datetime], Mapping[str, str]]: The + access token, new or current refresh token, expiration, and additional data + returned by the token endpoint. + + Raises: + google.auth.exceptions.RefreshError: If the token endpoint returned + an error. + + .. _rfc6748 section 6: https://tools.ietf.org/html/rfc6749#section-6 + """ + body = { + "grant_type": client._REFRESH_GRANT_TYPE, + "client_id": client_id, + "client_secret": client_secret, + "refresh_token": refresh_token, + } + if scopes: + body["scope"] = " ".join(scopes) + if rapt_token: + body["rapt"] = rapt_token + + response_data = await _token_endpoint_request( + request, token_uri, body, can_retry=can_retry + ) + return client._handle_refresh_grant_response(response_data, refresh_token) diff --git a/packages/google-auth/google/oauth2/_credentials_async.py b/packages/google-auth/google/oauth2/_credentials_async.py new file mode 100644 index 000000000000..b5561aae0229 --- /dev/null +++ b/packages/google-auth/google/oauth2/_credentials_async.py @@ -0,0 +1,118 @@ +# 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. + +"""OAuth 2.0 Async Credentials. + +This module provides credentials based on OAuth 2.0 access and refresh tokens. +These credentials usually access resources on behalf of a user (resource +owner). + +Specifically, this is intended to use access tokens acquired using the +`Authorization Code grant`_ and can refresh those tokens using a +optional `refresh token`_. + +Obtaining the initial access and refresh token is outside of the scope of this +module. Consult `rfc6749 section 4.1`_ for complete details on the +Authorization Code grant flow. + +.. _Authorization Code grant: https://tools.ietf.org/html/rfc6749#section-1.3.1 +.. _refresh token: https://tools.ietf.org/html/rfc6749#section-6 +.. _rfc6749 section 4.1: https://tools.ietf.org/html/rfc6749#section-4.1 +""" + +from google.auth import _credentials_async as credentials +from google.auth import _helpers +from google.auth import exceptions +from google.oauth2 import _reauth_async as reauth +from google.oauth2 import credentials as oauth2_credentials + + +class Credentials(oauth2_credentials.Credentials): + """Credentials using OAuth 2.0 access and refresh tokens. + + The credentials are considered immutable. If you want to modify the + quota project, use :meth:`with_quota_project` or :: + + credentials = credentials.with_quota_project('myproject-123) + """ + + @_helpers.copy_docstring(credentials.Credentials) + async def refresh(self, request): + if ( + self._refresh_token is None + or self._token_uri is None + or self._client_id is None + or self._client_secret is None + ): + raise exceptions.RefreshError( + "The credentials do not contain the necessary fields need to " + "refresh the access token. You must specify refresh_token, " + "token_uri, client_id, and client_secret." + ) + + ( + access_token, + refresh_token, + expiry, + grant_response, + rapt_token, + ) = await reauth.refresh_grant( + request, + self._token_uri, + self._refresh_token, + self._client_id, + self._client_secret, + scopes=self._scopes, + rapt_token=self._rapt_token, + enable_reauth_refresh=self._enable_reauth_refresh, + ) + + self.token = access_token + self.expiry = expiry + self._refresh_token = refresh_token + self._id_token = grant_response.get("id_token") + self._rapt_token = rapt_token + + if self._scopes and "scope" in grant_response: + requested_scopes = frozenset(self._scopes) + granted_scopes = frozenset(grant_response["scope"].split()) + scopes_requested_but_not_granted = requested_scopes - granted_scopes + if scopes_requested_but_not_granted: + raise exceptions.RefreshError( + "Not all requested scopes were granted by the " + "authorization server, missing scopes {}.".format( + ", ".join(scopes_requested_but_not_granted) + ) + ) + + @_helpers.copy_docstring(credentials.Credentials) + async def before_request(self, request, method, url, headers): + if not self.valid: + await self.refresh(request) + self.apply(headers) + + +class UserAccessTokenCredentials(oauth2_credentials.UserAccessTokenCredentials): + """Access token credentials for user account. + + Obtain the access token for a given user account or the current active + user account with the ``gcloud auth print-access-token`` command. + + Args: + account (Optional[str]): Account to get the access token for. If not + specified, the current active account will be used. + quota_project_id (Optional[str]): The project ID used for quota + and billing. + + """ diff --git a/packages/google-auth/google/oauth2/_id_token_async.py b/packages/google-auth/google/oauth2/_id_token_async.py new file mode 100644 index 000000000000..a7f77a1c785b --- /dev/null +++ b/packages/google-auth/google/oauth2/_id_token_async.py @@ -0,0 +1,287 @@ +# 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. + +"""Google ID Token helpers. + +Provides support for verifying `OpenID Connect ID Tokens`_, especially ones +generated by Google infrastructure. + +To parse and verify an ID Token issued by Google's OAuth 2.0 authorization +server use :func:`verify_oauth2_token`. To verify an ID Token issued by +Firebase, use :func:`verify_firebase_token`. + +A general purpose ID Token verifier is available as :func:`verify_token`. + +Example:: + + from google.oauth2 import _id_token_async + from google.auth.transport import aiohttp_requests + + request = aiohttp_requests.Request() + + id_info = await _id_token_async.verify_oauth2_token( + token, request, 'my-client-id.example.com') + + if id_info['iss'] != 'https://accounts.google.com': + raise ValueError('Wrong issuer.') + + userid = id_info['sub'] + +By default, this will re-fetch certificates for each verification. Because +Google's public keys are only changed infrequently (on the order of once per +day), you may wish to take advantage of caching to reduce latency and the +potential for network errors. This can be accomplished using an external +library like `CacheControl`_ to create a cache-aware +:class:`google.auth.transport.Request`:: + + import cachecontrol + import google.auth.transport.requests + import requests + + session = requests.session() + cached_session = cachecontrol.CacheControl(session) + request = google.auth.transport.requests.Request(session=cached_session) + +.. _OpenID Connect ID Token: + http://openid.net/specs/openid-connect-core-1_0.html#IDToken +.. _CacheControl: https://cachecontrol.readthedocs.io +""" + +import http.client as http_client +import json +import os + +from google.auth import environment_vars +from google.auth import exceptions +from google.auth import jwt +from google.auth.transport import requests +from google.oauth2 import id_token as sync_id_token + + +async def _fetch_certs(request, certs_url): + """Fetches certificates. + + Google-style cerificate endpoints return JSON in the format of + ``{'key id': 'x509 certificate'}``. + + Args: + request (google.auth.transport.Request): The object used to make + HTTP requests. This must be an aiohttp request. + certs_url (str): The certificate endpoint URL. + + Returns: + Mapping[str, str]: A mapping of public key ID to x.509 certificate + data. + """ + response = await request(certs_url, method="GET") + + if response.status != http_client.OK: + raise exceptions.TransportError( + "Could not fetch certificates at {}".format(certs_url) + ) + + data = await response.content() + + return json.loads(data) + + +async def verify_token( + id_token, + request, + audience=None, + certs_url=sync_id_token._GOOGLE_OAUTH2_CERTS_URL, + clock_skew_in_seconds=0, +): + """Verifies an ID token and returns the decoded token. + + Args: + id_token (Union[str, bytes]): The encoded token. + request (google.auth.transport.Request): The object used to make + HTTP requests. This must be an aiohttp request. + audience (str): The audience that this token is intended for. If None + then the audience is not verified. + certs_url (str): The URL that specifies the certificates to use to + verify the token. This URL should return JSON in the format of + ``{'key id': 'x509 certificate'}``. + clock_skew_in_seconds (int): The clock skew used for `iat` and `exp` + validation. + + Returns: + Mapping[str, Any]: The decoded token. + """ + certs = await _fetch_certs(request, certs_url) + + return jwt.decode( + id_token, + certs=certs, + audience=audience, + clock_skew_in_seconds=clock_skew_in_seconds, + ) + + +async def verify_oauth2_token( + id_token, request, audience=None, clock_skew_in_seconds=0 +): + """Verifies an ID Token issued by Google's OAuth 2.0 authorization server. + + Args: + id_token (Union[str, bytes]): The encoded token. + request (google.auth.transport.Request): The object used to make + HTTP requests. This must be an aiohttp request. + audience (str): The audience that this token is intended for. This is + typically your application's OAuth 2.0 client ID. If None then the + audience is not verified. + clock_skew_in_seconds (int): The clock skew used for `iat` and `exp` + validation. + + Returns: + Mapping[str, Any]: The decoded token. + + Raises: + exceptions.GoogleAuthError: If the issuer is invalid. + """ + idinfo = await verify_token( + id_token, + request, + audience=audience, + certs_url=sync_id_token._GOOGLE_OAUTH2_CERTS_URL, + clock_skew_in_seconds=clock_skew_in_seconds, + ) + + if idinfo["iss"] not in sync_id_token._GOOGLE_ISSUERS: + raise exceptions.GoogleAuthError( + "Wrong issuer. 'iss' should be one of the following: {}".format( + sync_id_token._GOOGLE_ISSUERS + ) + ) + + return idinfo + + +async def verify_firebase_token( + id_token, request, audience=None, clock_skew_in_seconds=0 +): + """Verifies an ID Token issued by Firebase Authentication. + + Args: + id_token (Union[str, bytes]): The encoded token. + request (google.auth.transport.Request): The object used to make + HTTP requests. This must be an aiohttp request. + audience (str): The audience that this token is intended for. This is + typically your Firebase application ID. If None then the audience + is not verified. + clock_skew_in_seconds (int): The clock skew used for `iat` and `exp` + validation. + + Returns: + Mapping[str, Any]: The decoded token. + """ + return await verify_token( + id_token, + request, + audience=audience, + certs_url=sync_id_token._GOOGLE_APIS_CERTS_URL, + clock_skew_in_seconds=clock_skew_in_seconds, + ) + + +async 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. + See https://google.aip.dev/auth/4110. + + 1. 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. + 2. If the application is running in Compute Engine, App Engine or Cloud Run, + then the ID token are obtained from the metadata server. + 3. 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_async + import google.auth.transport.aiohttp_requests + + request = google.auth.transport.aiohttp_requests.Request() + target_audience = "https://pubsub.googleapis.com" + + id_token = await google.oauth2._id_token_async.fetch_id_token(request, target_audience) + + Args: + request (google.auth.transport.aiohttp_requests.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. Try to get credentials from the GOOGLE_APPLICATION_CREDENTIALS environment + # variable. + credentials_filename = os.environ.get(environment_vars.CREDENTIALS) + if credentials_filename: + if not ( + os.path.exists(credentials_filename) + and os.path.isfile(credentials_filename) + ): + raise exceptions.DefaultCredentialsError( + "GOOGLE_APPLICATION_CREDENTIALS path is either not found or invalid." + ) + + try: + with open(credentials_filename, "r") as f: + from google.oauth2 import _service_account_async as service_account + + info = json.load(f) + if info.get("type") == "service_account": + credentials = ( + service_account.IDTokenCredentials.from_service_account_info( + info, target_audience=audience + ) + ) + await credentials.refresh(request) + return credentials.token + except ValueError as caught_exc: + new_exc = exceptions.DefaultCredentialsError( + "GOOGLE_APPLICATION_CREDENTIALS is not valid service account credentials.", + caught_exc, + ) + raise new_exc from caught_exc + + # 2. 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 + from google.auth.compute_engine import _metadata + + request_new = requests.Request() + if _metadata.ping(request_new): + credentials = compute_engine.IDTokenCredentials( + request_new, audience, use_metadata_identity_endpoint=True + ) + credentials.refresh(request_new) + return credentials.token + except (ImportError, exceptions.TransportError): + pass + + raise exceptions.DefaultCredentialsError( + "Neither metadata server or valid service account credentials are found." + ) diff --git a/packages/google-auth/google/oauth2/_reauth_async.py b/packages/google-auth/google/oauth2/_reauth_async.py new file mode 100644 index 000000000000..eeb8e9fb0273 --- /dev/null +++ b/packages/google-auth/google/oauth2/_reauth_async.py @@ -0,0 +1,330 @@ +# Copyright 2021 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. + +"""A module that provides functions for handling rapt authentication. + +Reauth is a process of obtaining additional authentication (such as password, +security token, etc.) while refreshing OAuth 2.0 credentials for a user. + +Credentials that use the Reauth flow must have the reauth scope, +``https://www.googleapis.com/auth/accounts.reauth``. + +This module provides a high-level function for executing the Reauth process, +:func:`refresh_grant`, and lower-level helpers for doing the individual +steps of the reauth process. + +Those steps are: + +1. Obtaining a list of challenges from the reauth server. +2. Running through each challenge and sending the result back to the reauth + server. +3. Refreshing the access token using the returned rapt token. +""" + +import sys + +from google.auth import exceptions +from google.oauth2 import _client +from google.oauth2 import _client_async +from google.oauth2 import challenges +from google.oauth2 import reauth + + +async def _get_challenges( + request, supported_challenge_types, access_token, requested_scopes=None +): + """Does initial request to reauth API to get the challenges. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. This must be an aiohttp request. + supported_challenge_types (Sequence[str]): list of challenge names + supported by the manager. + access_token (str): Access token with reauth scopes. + requested_scopes (Optional(Sequence[str])): Authorized scopes for the credentials. + + Returns: + dict: The response from the reauth API. + """ + body = {"supportedChallengeTypes": supported_challenge_types} + if requested_scopes: + body["oauthScopesForDomainPolicyLookup"] = requested_scopes + + return await _client_async._token_endpoint_request( + request, + reauth._REAUTH_API + ":start", + body, + access_token=access_token, + use_json=True, + ) + + +async def _send_challenge_result( + request, session_id, challenge_id, client_input, access_token +): + """Attempt to refresh access token by sending next challenge result. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. This must be an aiohttp request. + session_id (str): session id returned by the initial reauth call. + challenge_id (str): challenge id returned by the initial reauth call. + client_input: dict with a challenge-specific client input. For example: + ``{'credential': password}`` for password challenge. + access_token (str): Access token with reauth scopes. + + Returns: + dict: The response from the reauth API. + """ + body = { + "sessionId": session_id, + "challengeId": challenge_id, + "action": "RESPOND", + "proposalResponse": client_input, + } + + return await _client_async._token_endpoint_request( + request, + reauth._REAUTH_API + "/{}:continue".format(session_id), + body, + access_token=access_token, + use_json=True, + ) + + +async def _run_next_challenge(msg, request, access_token): + """Get the next challenge from msg and run it. + + Args: + msg (dict): Reauth API response body (either from the initial request to + https://reauth.googleapis.com/v2/sessions:start or from sending the + previous challenge response to + https://reauth.googleapis.com/v2/sessions/id:continue) + request (google.auth.transport.Request): A callable used to make + HTTP requests. This must be an aiohttp request. + access_token (str): reauth access token + + Returns: + dict: The response from the reauth API. + + Raises: + google.auth.exceptions.ReauthError: if reauth failed. + """ + for challenge in msg["challenges"]: + if challenge["status"] != "READY": + # Skip non-activated challenges. + continue + c = challenges.AVAILABLE_CHALLENGES.get(challenge["challengeType"], None) + if not c: + raise exceptions.ReauthFailError( + "Unsupported challenge type {0}. Supported types: {1}".format( + challenge["challengeType"], + ",".join(list(challenges.AVAILABLE_CHALLENGES.keys())), + ) + ) + if not c.is_locally_eligible: + raise exceptions.ReauthFailError( + "Challenge {0} is not locally eligible".format( + challenge["challengeType"] + ) + ) + client_input = c.obtain_challenge_input(challenge) + if not client_input: + return None + return await _send_challenge_result( + request, + msg["sessionId"], + challenge["challengeId"], + client_input, + access_token, + ) + return None + + +async def _obtain_rapt(request, access_token, requested_scopes): + """Given an http request method and reauth access token, get rapt token. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. This must be an aiohttp request. + access_token (str): reauth access token + requested_scopes (Sequence[str]): scopes required by the client application + + Returns: + str: The rapt token. + + Raises: + google.auth.exceptions.ReauthError: if reauth failed + """ + msg = await _get_challenges( + request, + list(challenges.AVAILABLE_CHALLENGES.keys()), + access_token, + requested_scopes, + ) + + if msg["status"] == reauth._AUTHENTICATED: + return msg["encodedProofOfReauthToken"] + + for _ in range(0, reauth.RUN_CHALLENGE_RETRY_LIMIT): + if not ( + msg["status"] == reauth._CHALLENGE_REQUIRED + or msg["status"] == reauth._CHALLENGE_PENDING + ): + raise exceptions.ReauthFailError( + "Reauthentication challenge failed due to API error: {}".format( + msg["status"] + ) + ) + + if not reauth.is_interactive(): + raise exceptions.ReauthFailError( + "Reauthentication challenge could not be answered because you are not" + " in an interactive session." + ) + + msg = await _run_next_challenge(msg, request, access_token) + + if msg["status"] == reauth._AUTHENTICATED: + return msg["encodedProofOfReauthToken"] + + # If we got here it means we didn't get authenticated. + raise exceptions.ReauthFailError("Failed to obtain rapt token.") + + +async def get_rapt_token( + request, client_id, client_secret, refresh_token, token_uri, scopes=None +): + """Given an http request method and refresh_token, get rapt token. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. This must be an aiohttp request. + client_id (str): client id to get access token for reauth scope. + client_secret (str): client secret for the client_id + refresh_token (str): refresh token to refresh access token + token_uri (str): uri to refresh access token + scopes (Optional(Sequence[str])): scopes required by the client application + + Returns: + str: The rapt token. + Raises: + google.auth.exceptions.RefreshError: If reauth failed. + """ + sys.stderr.write("Reauthentication required.\n") + + # Get access token for reauth. + access_token, _, _, _ = await _client_async.refresh_grant( + request=request, + client_id=client_id, + client_secret=client_secret, + refresh_token=refresh_token, + token_uri=token_uri, + scopes=[reauth._REAUTH_SCOPE], + ) + + # Get rapt token from reauth API. + rapt_token = await _obtain_rapt(request, access_token, requested_scopes=scopes) + + return rapt_token + + +async def refresh_grant( + request, + token_uri, + refresh_token, + client_id, + client_secret, + scopes=None, + rapt_token=None, + enable_reauth_refresh=False, +): + """Implements the reauthentication flow. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. This must be an aiohttp request. + token_uri (str): The OAuth 2.0 authorizations server's token endpoint + URI. + refresh_token (str): The refresh token to use to get a new access + token. + client_id (str): The OAuth 2.0 application's client ID. + client_secret (str): The Oauth 2.0 appliaction's client secret. + scopes (Optional(Sequence[str])): Scopes to request. If present, all + scopes must be authorized for the refresh token. Useful if refresh + token has a wild card scope (e.g. + 'https://www.googleapis.com/auth/any-api'). + rapt_token (Optional(str)): The rapt token for reauth. + enable_reauth_refresh (Optional[bool]): Whether reauth refresh flow + should be used. The default value is False. This option is for + gcloud only, other users should use the default value. + + Returns: + Tuple[str, Optional[str], Optional[datetime], Mapping[str, str], str]: The + access token, new refresh token, expiration, the additional data + returned by the token endpoint, and the rapt token. + + Raises: + google.auth.exceptions.RefreshError: If the token endpoint returned + an error. + """ + body = { + "grant_type": _client._REFRESH_GRANT_TYPE, + "client_id": client_id, + "client_secret": client_secret, + "refresh_token": refresh_token, + } + if scopes: + body["scope"] = " ".join(scopes) + if rapt_token: + body["rapt"] = rapt_token + + ( + response_status_ok, + response_data, + retryable_error, + ) = await _client_async._token_endpoint_request_no_throw(request, token_uri, body) + if ( + not response_status_ok + and response_data.get("error") == reauth._REAUTH_NEEDED_ERROR + and ( + response_data.get("error_subtype") + == reauth._REAUTH_NEEDED_ERROR_INVALID_RAPT + or response_data.get("error_subtype") + == reauth._REAUTH_NEEDED_ERROR_RAPT_REQUIRED + ) + ): + if not enable_reauth_refresh: + raise exceptions.RefreshError( + "Reauthentication is needed. Please run `gcloud auth application-default login` to reauthenticate." + ) + + rapt_token = await get_rapt_token( + request, client_id, client_secret, refresh_token, token_uri, scopes=scopes + ) + body["rapt"] = rapt_token + ( + response_status_ok, + response_data, + retryable_error, + ) = await _client_async._token_endpoint_request_no_throw( + request, token_uri, body + ) + + if not response_status_ok: + _client._handle_error_response(response_data, retryable_error) + refresh_response = _client._handle_refresh_grant_response( + response_data, refresh_token + ) + return refresh_response + (rapt_token,) diff --git a/packages/google-auth/google/oauth2/_service_account_async.py b/packages/google-auth/google/oauth2/_service_account_async.py new file mode 100644 index 000000000000..cfd315a7ff1f --- /dev/null +++ b/packages/google-auth/google/oauth2/_service_account_async.py @@ -0,0 +1,132 @@ +# 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. + +"""Service Accounts: JSON Web Token (JWT) Profile for OAuth 2.0 + +NOTE: This file adds asynchronous refresh methods to both credentials +classes, and therefore async/await syntax is required when calling this +method when using service account credentials with asynchronous functionality. +Otherwise, all other methods are inherited from the regular service account +credentials file google.oauth2.service_account + +""" + +from google.auth import _credentials_async as credentials_async +from google.auth import _helpers +from google.oauth2 import _client_async +from google.oauth2 import service_account + + +class Credentials( + service_account.Credentials, credentials_async.Scoped, credentials_async.Credentials +): + """Service account credentials + + Usually, you'll create these credentials with one of the helper + constructors. To create credentials using a Google service account + private key JSON file:: + + credentials = _service_account_async.Credentials.from_service_account_file( + 'service-account.json') + + Or if you already have the service account file loaded:: + + service_account_info = json.load(open('service_account.json')) + credentials = _service_account_async.Credentials.from_service_account_info( + service_account_info) + + Both helper methods pass on arguments to the constructor, so you can + specify additional scopes and a subject if necessary:: + + credentials = _service_account_async.Credentials.from_service_account_file( + 'service-account.json', + scopes=['email'], + subject='user@example.com') + + The credentials are considered immutable. If you want to modify the scopes + or the subject used for delegation, use :meth:`with_scopes` or + :meth:`with_subject`:: + + scoped_credentials = credentials.with_scopes(['email']) + delegated_credentials = credentials.with_subject(subject) + + To add a quota project, use :meth:`with_quota_project`:: + + credentials = credentials.with_quota_project('myproject-123') + """ + + @_helpers.copy_docstring(credentials_async.Credentials) + async def refresh(self, request): + assertion = self._make_authorization_grant_assertion() + access_token, expiry, _ = await _client_async.jwt_grant( + request, self._token_uri, assertion + ) + self.token = access_token + self.expiry = expiry + + +class IDTokenCredentials( + service_account.IDTokenCredentials, + credentials_async.Signing, + credentials_async.Credentials, +): + """Open ID Connect ID Token-based service account credentials. + + These credentials are largely similar to :class:`.Credentials`, but instead + of using an OAuth 2.0 Access Token as the bearer token, they use an Open + ID Connect ID Token as the bearer token. These credentials are useful when + communicating to services that require ID Tokens and can not accept access + tokens. + + Usually, you'll create these credentials with one of the helper + constructors. To create credentials using a Google service account + private key JSON file:: + + credentials = ( + _service_account_async.IDTokenCredentials.from_service_account_file( + 'service-account.json')) + + Or if you already have the service account file loaded:: + + service_account_info = json.load(open('service_account.json')) + credentials = ( + _service_account_async.IDTokenCredentials.from_service_account_info( + service_account_info)) + + Both helper methods pass on arguments to the constructor, so you can + specify additional scopes and a subject if necessary:: + + credentials = ( + _service_account_async.IDTokenCredentials.from_service_account_file( + 'service-account.json', + scopes=['email'], + subject='user@example.com')) + + The credentials are considered immutable. If you want to modify the scopes + or the subject used for delegation, use :meth:`with_scopes` or + :meth:`with_subject`:: + + scoped_credentials = credentials.with_scopes(['email']) + delegated_credentials = credentials.with_subject(subject) + + """ + + @_helpers.copy_docstring(credentials_async.Credentials) + async def refresh(self, request): + assertion = self._make_authorization_grant_assertion() + access_token, expiry, _ = await _client_async.id_token_jwt_grant( + request, self._token_uri, assertion + ) + self.token = access_token + self.expiry = expiry diff --git a/packages/google-auth/google/oauth2/challenges.py b/packages/google-auth/google/oauth2/challenges.py new file mode 100644 index 000000000000..6468498bcb6d --- /dev/null +++ b/packages/google-auth/google/oauth2/challenges.py @@ -0,0 +1,281 @@ +# Copyright 2021 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. + +""" Challenges for reauthentication. +""" + +import abc +import base64 +import getpass +import sys + +from google.auth import _helpers +from google.auth import exceptions +from google.oauth2 import webauthn_handler_factory +from google.oauth2.webauthn_types import ( + AuthenticationExtensionsClientInputs, + GetRequest, + PublicKeyCredentialDescriptor, +) + + +REAUTH_ORIGIN = "https://accounts.google.com" +SAML_CHALLENGE_MESSAGE = ( + "Please run `gcloud auth login` to complete reauthentication with SAML." +) +WEBAUTHN_TIMEOUT_MS = 120000 # Two minute timeout + + +def get_user_password(text): + """Get password from user. + + Override this function with a different logic if you are using this library + outside a CLI. + + Args: + text (str): message for the password prompt. + + Returns: + str: password string. + """ + return getpass.getpass(text) + + +class ReauthChallenge(metaclass=abc.ABCMeta): + """Base class for reauth challenges.""" + + @property + @abc.abstractmethod + def name(self): # pragma: NO COVER + """Returns the name of the challenge.""" + raise NotImplementedError("name property must be implemented") + + @property + @abc.abstractmethod + def is_locally_eligible(self): # pragma: NO COVER + """Returns true if a challenge is supported locally on this machine.""" + raise NotImplementedError("is_locally_eligible property must be implemented") + + @abc.abstractmethod + def obtain_challenge_input(self, metadata): # pragma: NO COVER + """Performs logic required to obtain credentials and returns it. + + Args: + metadata (Mapping): challenge metadata returned in the 'challenges' field in + the initial reauth request. Includes the 'challengeType' field + and other challenge-specific fields. + + Returns: + response that will be send to the reauth service as the content of + the 'proposalResponse' field in the request body. Usually a dict + with the keys specific to the challenge. For example, + ``{'credential': password}`` for password challenge. + """ + raise NotImplementedError("obtain_challenge_input method must be implemented") + + +class PasswordChallenge(ReauthChallenge): + """Challenge that asks for user's password.""" + + @property + def name(self): + return "PASSWORD" + + @property + def is_locally_eligible(self): + return True + + @_helpers.copy_docstring(ReauthChallenge) + def obtain_challenge_input(self, unused_metadata): + passwd = get_user_password("Please enter your password:") + if not passwd: + passwd = " " # avoid the server crashing in case of no password :D + return {"credential": passwd} + + +class SecurityKeyChallenge(ReauthChallenge): + """Challenge that asks for user's security key touch.""" + + @property + def name(self): + return "SECURITY_KEY" + + @property + def is_locally_eligible(self): + return True + + @_helpers.copy_docstring(ReauthChallenge) + def obtain_challenge_input(self, metadata): + # Check if there is an available Webauthn Handler, if not use pyu2f + try: + factory = webauthn_handler_factory.WebauthnHandlerFactory() + webauthn_handler = factory.get_handler() + if webauthn_handler is not None: + sys.stderr.write("Please insert and touch your security key\n") + return self._obtain_challenge_input_webauthn(metadata, webauthn_handler) + except Exception: + # Attempt pyu2f if exception in webauthn flow + pass + + try: + import pyu2f.convenience.authenticator # type: ignore + import pyu2f.errors # type: ignore + import pyu2f.model # type: ignore + except ImportError: + raise exceptions.ReauthFailError( + "pyu2f dependency is required to use Security key reauth feature. " + "It can be installed via `pip install pyu2f` or `pip install google-auth[reauth]`." + ) + sk = metadata["securityKey"] + challenges = sk["challenges"] + # Read both 'applicationId' and 'relyingPartyId', if they are the same, use + # applicationId, if they are different, use relyingPartyId first and retry + # with applicationId + application_id = sk["applicationId"] + relying_party_id = sk["relyingPartyId"] + + if application_id != relying_party_id: + application_parameters = [relying_party_id, application_id] + else: + application_parameters = [application_id] + + challenge_data = [] + for c in challenges: + kh = c["keyHandle"].encode("ascii") + key = pyu2f.model.RegisteredKey(bytearray(base64.urlsafe_b64decode(kh))) + challenge = c["challenge"].encode("ascii") + challenge = base64.urlsafe_b64decode(challenge) + challenge_data.append({"key": key, "challenge": challenge}) + + # Track number of tries to suppress error message until all application_parameters + # are tried. + tries = 0 + for app_id in application_parameters: + try: + tries += 1 + api = pyu2f.convenience.authenticator.CreateCompositeAuthenticator( + REAUTH_ORIGIN + ) + response = api.Authenticate( + app_id, challenge_data, print_callback=sys.stderr.write + ) + return {"securityKey": response} + except pyu2f.errors.U2FError as e: + if e.code == pyu2f.errors.U2FError.DEVICE_INELIGIBLE: + # Only show error if all app_ids have been tried + if tries == len(application_parameters): + sys.stderr.write("Ineligible security key.\n") + return None + continue + if e.code == pyu2f.errors.U2FError.TIMEOUT: + sys.stderr.write( + "Timed out while waiting for security key touch.\n" + ) + else: + raise e + except pyu2f.errors.PluginError as e: + sys.stderr.write("Plugin error: {}.\n".format(e)) + continue + except pyu2f.errors.NoDeviceFoundError: + sys.stderr.write("No security key found.\n") + return None + + def _obtain_challenge_input_webauthn(self, metadata, webauthn_handler): + sk = metadata.get("securityKey") + if sk is None: + raise exceptions.InvalidValue("securityKey is None") + challenges = sk.get("challenges") + application_id = sk.get("applicationId") + relying_party_id = sk.get("relyingPartyId") + if challenges is None or len(challenges) < 1: + raise exceptions.InvalidValue("challenges is None or empty") + if application_id is None: + raise exceptions.InvalidValue("application_id is None") + if relying_party_id is None: + raise exceptions.InvalidValue("relying_party_id is None") + + allow_credentials = [] + for challenge in challenges: + kh = challenge.get("keyHandle") + if kh is None: + raise exceptions.InvalidValue("keyHandle is None") + key_handle = self._unpadded_urlsafe_b64recode(kh) + allow_credentials.append(PublicKeyCredentialDescriptor(id=key_handle)) + + extension = AuthenticationExtensionsClientInputs(appid=application_id) + + challenge = challenges[0].get("challenge") + if challenge is None: + raise exceptions.InvalidValue("challenge is None") + + get_request = GetRequest( + origin=REAUTH_ORIGIN, + rpid=relying_party_id, + challenge=self._unpadded_urlsafe_b64recode(challenge), + timeout_ms=WEBAUTHN_TIMEOUT_MS, + allow_credentials=allow_credentials, + user_verification="required", + extensions=extension, + ) + + try: + get_response = webauthn_handler.get(get_request) + except Exception as e: + sys.stderr.write("Webauthn Error: {}.\n".format(e)) + raise e + + response = { + "clientData": get_response.response.client_data_json, + "authenticatorData": get_response.response.authenticator_data, + "signatureData": get_response.response.signature, + "applicationId": application_id, + "keyHandle": get_response.id, + "securityKeyReplyType": 2, + } + return {"securityKey": response} + + def _unpadded_urlsafe_b64recode(self, s): + """Converts standard b64 encoded string to url safe b64 encoded string + with no padding.""" + b = base64.urlsafe_b64decode(s) + return base64.urlsafe_b64encode(b).decode().rstrip("=") + + +class SamlChallenge(ReauthChallenge): + """Challenge that asks the users to browse to their ID Providers. + + Currently SAML challenge is not supported. When obtaining the challenge + input, exception will be raised to instruct the users to run + `gcloud auth login` for reauthentication. + """ + + @property + def name(self): + return "SAML" + + @property + def is_locally_eligible(self): + return True + + def obtain_challenge_input(self, metadata): + # Magic Arch has not fully supported returning a proper dedirect URL + # for programmatic SAML users today. So we error our here and request + # users to use gcloud to complete a login. + raise exceptions.ReauthSamlChallengeFailError(SAML_CHALLENGE_MESSAGE) + + +AVAILABLE_CHALLENGES = { + challenge.name: challenge + for challenge in [SecurityKeyChallenge(), PasswordChallenge(), SamlChallenge()] +} diff --git a/packages/google-auth/google/oauth2/credentials.py b/packages/google-auth/google/oauth2/credentials.py new file mode 100644 index 000000000000..6e158089f31d --- /dev/null +++ b/packages/google-auth/google/oauth2/credentials.py @@ -0,0 +1,614 @@ +# Copyright 2016 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. + +"""OAuth 2.0 Credentials. + +This module provides credentials based on OAuth 2.0 access and refresh tokens. +These credentials usually access resources on behalf of a user (resource +owner). + +Specifically, this is intended to use access tokens acquired using the +`Authorization Code grant`_ and can refresh those tokens using a +optional `refresh token`_. + +Obtaining the initial access and refresh token is outside of the scope of this +module. Consult `rfc6749 section 4.1`_ for complete details on the +Authorization Code grant flow. + +.. _Authorization Code grant: https://tools.ietf.org/html/rfc6749#section-1.3.1 +.. _refresh token: https://tools.ietf.org/html/rfc6749#section-6 +.. _rfc6749 section 4.1: https://tools.ietf.org/html/rfc6749#section-4.1 +""" + +from datetime import datetime +import io +import json +import logging +import warnings + +from google.auth import _cloud_sdk +from google.auth import _helpers +from google.auth import credentials +from google.auth import exceptions +from google.auth import metrics +from google.oauth2 import reauth + +_LOGGER = logging.getLogger(__name__) + + +# The Google OAuth 2.0 token endpoint. Used for authorized user credentials. +_GOOGLE_OAUTH2_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token" + +# The Google OAuth 2.0 token info endpoint. Used for getting token info JSON from access tokens. +_GOOGLE_OAUTH2_TOKEN_INFO_ENDPOINT = "https://oauth2.googleapis.com/tokeninfo" + + +class Credentials(credentials.ReadOnlyScoped, credentials.CredentialsWithQuotaProject): + """Credentials using OAuth 2.0 access and refresh tokens. + + The credentials are considered immutable except the tokens and the token + expiry, which are updated after refresh. If you want to modify the quota + project, use :meth:`with_quota_project` or :: + + credentials = credentials.with_quota_project('myproject-123') + + Reauth is disabled by default. To enable reauth, set the + `enable_reauth_refresh` parameter to True in the constructor. Note that + reauth feature is intended for gcloud to use only. + If reauth is enabled, `pyu2f` dependency has to be installed in order to use security + key reauth feature. Dependency can be installed via `pip install pyu2f` or `pip install + google-auth[reauth]`. + """ + + def __init__( + self, + token, + refresh_token=None, + id_token=None, + token_uri=None, + client_id=None, + client_secret=None, + scopes=None, + default_scopes=None, + quota_project_id=None, + expiry=None, + rapt_token=None, + refresh_handler=None, + enable_reauth_refresh=False, + granted_scopes=None, + trust_boundary=None, + universe_domain=credentials.DEFAULT_UNIVERSE_DOMAIN, + account=None, + ): + """ + Args: + token (Optional(str)): The OAuth 2.0 access token. Can be None + if refresh information is provided. + refresh_token (str): The OAuth 2.0 refresh token. If specified, + credentials can be refreshed. + id_token (str): The Open ID Connect ID Token. + token_uri (str): The OAuth 2.0 authorization server's token + endpoint URI. Must be specified for refresh, can be left as + None if the token can not be refreshed. + client_id (str): The OAuth 2.0 client ID. Must be specified for + refresh, can be left as None if the token can not be refreshed. + client_secret(str): The OAuth 2.0 client secret. Must be specified + for refresh, can be left as None if the token can not be + refreshed. + scopes (Sequence[str]): The scopes used to obtain authorization. + This parameter is used by :meth:`has_scopes`. OAuth 2.0 + credentials can not request additional scopes after + authorization. The scopes must be derivable from the refresh + token if refresh information is provided (e.g. The refresh + token scopes are a superset of this or contain a wild card + scope like 'https://www.googleapis.com/auth/any-api'). + default_scopes (Sequence[str]): Default scopes passed by a + Google client library. Use 'scopes' for user-defined scopes. + quota_project_id (Optional[str]): The project ID used for quota and billing. + This project may be different from the project used to + create the credentials. + rapt_token (Optional[str]): The reauth Proof Token. + refresh_handler (Optional[Callable[[google.auth.transport.Request, Sequence[str]], [str, datetime]]]): + A callable which takes in the HTTP request callable and the list of + OAuth scopes and when called returns an access token string for the + requested scopes and its expiry datetime. This is useful when no + refresh tokens are provided and tokens are obtained by calling + some external process on demand. It is particularly useful for + retrieving downscoped tokens from a token broker. + enable_reauth_refresh (Optional[bool]): Whether reauth refresh flow + should be used. This flag is for gcloud to use only. + granted_scopes (Optional[Sequence[str]]): The scopes that were consented/granted by the user. + This could be different from the requested scopes and it could be empty if granted + and requested scopes were same. + trust_boundary (str): String representation of trust boundary meta. + universe_domain (Optional[str]): The universe domain. The default + universe domain is googleapis.com. + account (Optional[str]): The account associated with the credential. + """ + super(Credentials, self).__init__() + self.token = token + self.expiry = expiry + self._refresh_token = refresh_token + self._id_token = id_token + self._scopes = scopes + self._default_scopes = default_scopes + self._granted_scopes = granted_scopes + self._token_uri = token_uri + self._client_id = client_id + self._client_secret = client_secret + self._quota_project_id = quota_project_id + self._rapt_token = rapt_token + self.refresh_handler = refresh_handler + self._enable_reauth_refresh = enable_reauth_refresh + self._trust_boundary = trust_boundary + self._universe_domain = universe_domain or credentials.DEFAULT_UNIVERSE_DOMAIN + self._account = account or "" + self._cred_file_path = None + + def __getstate__(self): + """A __getstate__ method must exist for the __setstate__ to be called + This is identical to the default implementation. + See https://docs.python.org/3.7/library/pickle.html#object.__setstate__ + """ + state_dict = self.__dict__.copy() + # Remove _refresh_handler function as there are limitations pickling and + # unpickling certain callables (lambda, functools.partial instances) + # because they need to be importable. + # Instead, the refresh_handler setter should be used to repopulate this. + if "_refresh_handler" in state_dict: + del state_dict["_refresh_handler"] + + if "_refresh_worker" in state_dict: + del state_dict["_refresh_worker"] + return state_dict + + def __setstate__(self, d): + """Credentials pickled with older versions of the class do not have + all the attributes.""" + self.token = d.get("token") + self.expiry = d.get("expiry") + self._refresh_token = d.get("_refresh_token") + self._id_token = d.get("_id_token") + self._scopes = d.get("_scopes") + self._default_scopes = d.get("_default_scopes") + self._granted_scopes = d.get("_granted_scopes") + self._token_uri = d.get("_token_uri") + self._client_id = d.get("_client_id") + self._client_secret = d.get("_client_secret") + self._quota_project_id = d.get("_quota_project_id") + self._rapt_token = d.get("_rapt_token") + self._enable_reauth_refresh = d.get("_enable_reauth_refresh") + self._trust_boundary = d.get("_trust_boundary") + self._universe_domain = ( + d.get("_universe_domain") or credentials.DEFAULT_UNIVERSE_DOMAIN + ) + self._cred_file_path = d.get("_cred_file_path") + # The refresh_handler setter should be used to repopulate this. + self._refresh_handler = None + self._refresh_worker = None + self._use_non_blocking_refresh = d.get("_use_non_blocking_refresh", False) + self._account = d.get("_account", "") + + @property + def refresh_token(self): + """Optional[str]: The OAuth 2.0 refresh token.""" + return self._refresh_token + + @property + def scopes(self): + """Optional[str]: The OAuth 2.0 permission scopes.""" + return self._scopes + + @property + def granted_scopes(self): + """Optional[Sequence[str]]: The OAuth 2.0 permission scopes that were granted by the user.""" + return self._granted_scopes + + @property + def token_uri(self): + """Optional[str]: The OAuth 2.0 authorization server's token endpoint + URI.""" + return self._token_uri + + @property + def id_token(self): + """Optional[str]: The Open ID Connect ID Token. + + Depending on the authorization server and the scopes requested, this + may be populated when credentials are obtained and updated when + :meth:`refresh` is called. This token is a JWT. It can be verified + and decoded using :func:`google.oauth2.id_token.verify_oauth2_token`. + """ + return self._id_token + + @property + def client_id(self): + """Optional[str]: The OAuth 2.0 client ID.""" + return self._client_id + + @property + def client_secret(self): + """Optional[str]: The OAuth 2.0 client secret.""" + return self._client_secret + + @property + def requires_scopes(self): + """False: OAuth 2.0 credentials have their scopes set when + the initial token is requested and can not be changed.""" + return False + + @property + def rapt_token(self): + """Optional[str]: The reauth Proof Token.""" + return self._rapt_token + + @property + def refresh_handler(self): + """Returns the refresh handler if available. + + Returns: + Optional[Callable[[google.auth.transport.Request, Sequence[str]], [str, datetime]]]: + The current refresh handler. + """ + return self._refresh_handler + + @refresh_handler.setter + def refresh_handler(self, value): + """Updates the current refresh handler. + + Args: + value (Optional[Callable[[google.auth.transport.Request, Sequence[str]], [str, datetime]]]): + The updated value of the refresh handler. + + Raises: + TypeError: If the value is not a callable or None. + """ + if not callable(value) and value is not None: + raise TypeError("The provided refresh_handler is not a callable or None.") + self._refresh_handler = value + + @property + def account(self): + """str: The user account associated with the credential. If the account is unknown an empty string is returned.""" + return self._account + + def _make_copy(self): + cred = self.__class__( + self.token, + refresh_token=self.refresh_token, + id_token=self.id_token, + token_uri=self.token_uri, + client_id=self.client_id, + client_secret=self.client_secret, + scopes=self.scopes, + default_scopes=self.default_scopes, + granted_scopes=self.granted_scopes, + quota_project_id=self.quota_project_id, + rapt_token=self.rapt_token, + enable_reauth_refresh=self._enable_reauth_refresh, + trust_boundary=self._trust_boundary, + universe_domain=self._universe_domain, + account=self._account, + ) + cred._cred_file_path = self._cred_file_path + return cred + + @_helpers.copy_docstring(credentials.Credentials) + def get_cred_info(self): + if self._cred_file_path: + cred_info = { + "credential_source": self._cred_file_path, + "credential_type": "user credentials", + } + if self.account: + cred_info["principal"] = self.account + return cred_info + return None + + @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) + def with_quota_project(self, quota_project_id): + cred = self._make_copy() + cred._quota_project_id = quota_project_id + return cred + + @_helpers.copy_docstring(credentials.CredentialsWithTokenUri) + def with_token_uri(self, token_uri): + cred = self._make_copy() + cred._token_uri = token_uri + return cred + + def with_account(self, account): + """Returns a copy of these credentials with a modified account. + + Args: + account (str): The account to set + + Returns: + google.oauth2.credentials.Credentials: A new credentials instance. + """ + cred = self._make_copy() + cred._account = account + return cred + + @_helpers.copy_docstring(credentials.CredentialsWithUniverseDomain) + def with_universe_domain(self, universe_domain): + cred = self._make_copy() + cred._universe_domain = universe_domain + return cred + + def _metric_header_for_usage(self): + return metrics.CRED_TYPE_USER + + @_helpers.copy_docstring(credentials.Credentials) + def refresh(self, request): + if self._universe_domain != credentials.DEFAULT_UNIVERSE_DOMAIN: + raise exceptions.RefreshError( + "User credential refresh is only supported in the default " + "googleapis.com universe domain, but the current universe " + "domain is {}. If you created the credential with an access " + "token, it's likely that the provided token is expired now, " + "please update your code with a valid token.".format( + self._universe_domain + ) + ) + + scopes = self._scopes if self._scopes is not None else self._default_scopes + # Use refresh handler if available and no refresh token is + # available. This is useful in general when tokens are obtained by calling + # some external process on demand. It is particularly useful for retrieving + # downscoped tokens from a token broker. + if self._refresh_token is None and self.refresh_handler: + token, expiry = self.refresh_handler(request, scopes=scopes) + # Validate returned data. + if not isinstance(token, str): + raise exceptions.RefreshError( + "The refresh_handler returned token is not a string." + ) + if not isinstance(expiry, datetime): + raise exceptions.RefreshError( + "The refresh_handler returned expiry is not a datetime object." + ) + if _helpers.utcnow() >= expiry - _helpers.REFRESH_THRESHOLD: + raise exceptions.RefreshError( + "The credentials returned by the refresh_handler are " + "already expired." + ) + self.token = token + self.expiry = expiry + return + + if ( + self._refresh_token is None + or self._token_uri is None + or self._client_id is None + or self._client_secret is None + ): + raise exceptions.RefreshError( + "The credentials do not contain the necessary fields need to " + "refresh the access token. You must specify refresh_token, " + "token_uri, client_id, and client_secret." + ) + + ( + access_token, + refresh_token, + expiry, + grant_response, + rapt_token, + ) = reauth.refresh_grant( + request, + self._token_uri, + self._refresh_token, + self._client_id, + self._client_secret, + scopes=scopes, + rapt_token=self._rapt_token, + enable_reauth_refresh=self._enable_reauth_refresh, + ) + + self.token = access_token + self.expiry = expiry + self._refresh_token = refresh_token + self._id_token = grant_response.get("id_token") + self._rapt_token = rapt_token + + if scopes and "scope" in grant_response: + requested_scopes = frozenset(scopes) + self._granted_scopes = grant_response["scope"].split() + granted_scopes = frozenset(self._granted_scopes) + scopes_requested_but_not_granted = requested_scopes - granted_scopes + if scopes_requested_but_not_granted: + # User might be presented with unbundled scopes at the time of + # consent. So it is a valid scenario to not have all the requested + # scopes as part of granted scopes but log a warning in case the + # developer wants to debug the scenario. + _LOGGER.warning( + "Not all requested scopes were granted by the " + "authorization server, missing scopes {}.".format( + ", ".join(scopes_requested_but_not_granted) + ) + ) + + @classmethod + def from_authorized_user_info(cls, info, scopes=None): + """Creates a Credentials instance from parsed authorized user info. + + Args: + info (Mapping[str, str]): The authorized user info in Google + format. + scopes (Sequence[str]): Optional list of scopes to include in the + credentials. + + Returns: + google.oauth2.credentials.Credentials: The constructed + credentials. + + Raises: + ValueError: If the info is not in the expected format. + """ + keys_needed = set(("refresh_token", "client_id", "client_secret")) + missing = keys_needed.difference(info.keys()) + + if missing: + raise ValueError( + "Authorized user info was not in the expected format, missing " + "fields {}.".format(", ".join(missing)) + ) + + # access token expiry (datetime obj); auto-expire if not saved + expiry = info.get("expiry") + if expiry: + expiry = datetime.strptime( + expiry.rstrip("Z").split(".")[0], "%Y-%m-%dT%H:%M:%S" + ) + else: + expiry = _helpers.utcnow() - _helpers.REFRESH_THRESHOLD + + # process scopes, which needs to be a seq + if scopes is None and "scopes" in info: + scopes = info.get("scopes") + if isinstance(scopes, str): + scopes = scopes.split(" ") + + return cls( + token=info.get("token"), + refresh_token=info.get("refresh_token"), + token_uri=_GOOGLE_OAUTH2_TOKEN_ENDPOINT, # always overrides + scopes=scopes, + client_id=info.get("client_id"), + client_secret=info.get("client_secret"), + quota_project_id=info.get("quota_project_id"), # may not exist + expiry=expiry, + rapt_token=info.get("rapt_token"), # may not exist + trust_boundary=info.get("trust_boundary"), # may not exist + universe_domain=info.get("universe_domain"), # may not exist + account=info.get("account", ""), # may not exist + ) + + @classmethod + def from_authorized_user_file(cls, filename, scopes=None): + """Creates a Credentials instance from an authorized user json file. + + Args: + filename (str): The path to the authorized user json file. + scopes (Sequence[str]): Optional list of scopes to include in the + credentials. + + Returns: + google.oauth2.credentials.Credentials: The constructed + credentials. + + Raises: + ValueError: If the file is not in the expected format. + """ + with io.open(filename, "r", encoding="utf-8") as json_file: + data = json.load(json_file) + return cls.from_authorized_user_info(data, scopes) + + def to_json(self, strip=None): + """Utility function that creates a JSON representation of a Credentials + object. + + Args: + strip (Sequence[str]): Optional list of members to exclude from the + generated JSON. + + Returns: + str: A JSON representation of this instance. When converted into + a dictionary, it can be passed to from_authorized_user_info() + to create a new credential instance. + """ + prep = { + "token": self.token, + "refresh_token": self.refresh_token, + "token_uri": self.token_uri, + "client_id": self.client_id, + "client_secret": self.client_secret, + "scopes": self.scopes, + "rapt_token": self.rapt_token, + "universe_domain": self._universe_domain, + "account": self._account, + } + if self.expiry: # flatten expiry timestamp + prep["expiry"] = self.expiry.isoformat() + "Z" + + # Remove empty entries (those which are None) + prep = {k: v for k, v in prep.items() if v is not None} + + # Remove entries that explicitely need to be removed + if strip is not None: + prep = {k: v for k, v in prep.items() if k not in strip} + + return json.dumps(prep) + + +class UserAccessTokenCredentials(credentials.CredentialsWithQuotaProject): + """Access token credentials for user account. + + Obtain the access token for a given user account or the current active + user account with the ``gcloud auth print-access-token`` command. + + Args: + account (Optional[str]): Account to get the access token for. If not + specified, the current active account will be used. + quota_project_id (Optional[str]): The project ID used for quota + and billing. + """ + + def __init__(self, account=None, quota_project_id=None): + warnings.warn( + "UserAccessTokenCredentials is deprecated, please use " + "google.oauth2.credentials.Credentials instead. To use " + "that credential type, simply run " + "`gcloud auth application-default login` and let the " + "client libraries pick up the application default credentials." + ) + super(UserAccessTokenCredentials, self).__init__() + self._account = account + self._quota_project_id = quota_project_id + + def with_account(self, account): + """Create a new instance with the given account. + + Args: + account (str): Account to get the access token for. + + Returns: + google.oauth2.credentials.UserAccessTokenCredentials: The created + credentials with the given account. + """ + return self.__class__(account=account, quota_project_id=self._quota_project_id) + + @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) + def with_quota_project(self, quota_project_id): + return self.__class__(account=self._account, quota_project_id=quota_project_id) + + def refresh(self, request): + """Refreshes the access token. + + Args: + request (google.auth.transport.Request): This argument is required + by the base class interface but not used in this implementation, + so just set it to `None`. + + Raises: + google.auth.exceptions.UserAccessTokenError: If the access token + refresh failed. + """ + self.token = _cloud_sdk.get_auth_access_token(self._account) + + @_helpers.copy_docstring(credentials.Credentials) + def before_request(self, request, method, url, headers): + self.refresh(request) + self.apply(headers) diff --git a/packages/google-auth/google/oauth2/gdch_credentials.py b/packages/google-auth/google/oauth2/gdch_credentials.py new file mode 100644 index 000000000000..7410cfc2e05e --- /dev/null +++ b/packages/google-auth/google/oauth2/gdch_credentials.py @@ -0,0 +1,251 @@ +# 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. + +"""Experimental GDCH credentials support. +""" + +import datetime + +from google.auth import _helpers +from google.auth import _service_account_info +from google.auth import credentials +from google.auth import exceptions +from google.auth import jwt +from google.oauth2 import _client + + +TOKEN_EXCHANGE_TYPE = "urn:ietf:params:oauth:token-type:token-exchange" +ACCESS_TOKEN_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token" +SERVICE_ACCOUNT_TOKEN_TYPE = "urn:k8s:params:oauth:token-type:serviceaccount" +JWT_LIFETIME = datetime.timedelta(seconds=3600) # 1 hour + + +class ServiceAccountCredentials(credentials.Credentials): + """Credentials for GDCH (`Google Distributed Cloud Hosted`_) for service + account users. + + .. _Google Distributed Cloud Hosted: + https://cloud.google.com/blog/topics/hybrid-cloud/\ + announcing-google-distributed-cloud-edge-and-hosted + + To create a GDCH service account credential, first create a JSON file of + the following format:: + + { + "type": "gdch_service_account", + "format_version": "1", + "project": "", + "private_key_id": "", + "private_key": "-----BEGIN EC PRIVATE KEY-----\n\n-----END EC PRIVATE KEY-----\n", + "name": "", + "ca_cert_path": "", + "token_uri": "https://service-identity./authenticate" + } + + The "format_version" field stands for the format of the JSON file. For now + it is always "1". The `private_key_id` and `private_key` is used for signing. + The `ca_cert_path` is used for token server TLS certificate verification. + + After the JSON file is created, set `GOOGLE_APPLICATION_CREDENTIALS` environment + variable to the JSON file path, then use the following code to create the + credential:: + + import google.auth + + credential, _ = google.auth.default() + credential = credential.with_gdch_audience("") + + We can also create the credential directly:: + + from google.oauth import gdch_credentials + + credential = gdch_credentials.ServiceAccountCredentials.from_service_account_file("") + credential = credential.with_gdch_audience("") + + The token is obtained in the following way. This class first creates a + self signed JWT. It uses the `name` value as the `iss` and `sub` claim, and + the `token_uri` as the `aud` claim, and signs the JWT with the `private_key`. + It then sends the JWT to the `token_uri` to exchange a final token for + `audience`. + """ + + def __init__( + self, signer, service_identity_name, project, audience, token_uri, ca_cert_path + ): + """ + Args: + signer (google.auth.crypt.Signer): The signer used to sign JWTs. + service_identity_name (str): The service identity name. It will be + used as the `iss` and `sub` claim in the self signed JWT. + project (str): The project. + audience (str): The audience for the final token. + token_uri (str): The token server uri. + ca_cert_path (str): The CA cert path for token server side TLS + certificate verification. If the token server uses well known + CA, then this parameter can be `None`. + """ + super(ServiceAccountCredentials, self).__init__() + self._signer = signer + self._service_identity_name = service_identity_name + self._project = project + self._audience = audience + self._token_uri = token_uri + self._ca_cert_path = ca_cert_path + + def _create_jwt(self): + now = _helpers.utcnow() + expiry = now + JWT_LIFETIME + iss_sub_value = "system:serviceaccount:{}:{}".format( + self._project, self._service_identity_name + ) + + payload = { + "iss": iss_sub_value, + "sub": iss_sub_value, + "aud": self._token_uri, + "iat": _helpers.datetime_to_secs(now), + "exp": _helpers.datetime_to_secs(expiry), + } + + return _helpers.from_bytes(jwt.encode(self._signer, payload)) + + @_helpers.copy_docstring(credentials.Credentials) + def refresh(self, request): + import google.auth.transport.requests + + if not isinstance(request, google.auth.transport.requests.Request): + raise exceptions.RefreshError( + "For GDCH service account credentials, request must be a google.auth.transport.requests.Request object" + ) + + # Create a self signed JWT, and do token exchange. + jwt_token = self._create_jwt() + request_body = { + "grant_type": TOKEN_EXCHANGE_TYPE, + "audience": self._audience, + "requested_token_type": ACCESS_TOKEN_TOKEN_TYPE, + "subject_token": jwt_token, + "subject_token_type": SERVICE_ACCOUNT_TOKEN_TYPE, + } + response_data = _client._token_endpoint_request( + request, + self._token_uri, + request_body, + access_token=None, + use_json=True, + verify=self._ca_cert_path, + ) + + self.token, _, self.expiry, _ = _client._handle_refresh_grant_response( + response_data, None + ) + + def with_gdch_audience(self, audience): + """Create a copy of GDCH credentials with the specified audience. + + Args: + audience (str): The intended audience for GDCH credentials. + """ + return self.__class__( + self._signer, + self._service_identity_name, + self._project, + audience, + self._token_uri, + self._ca_cert_path, + ) + + @classmethod + def _from_signer_and_info(cls, signer, info): + """Creates a Credentials instance from a signer and service account + info. + + Args: + signer (google.auth.crypt.Signer): The signer used to sign JWTs. + info (Mapping[str, str]): The service account info. + + Returns: + google.oauth2.gdch_credentials.ServiceAccountCredentials: The constructed + credentials. + + Raises: + ValueError: If the info is not in the expected format. + """ + if info["format_version"] != "1": + raise ValueError("Only format version 1 is supported") + + return cls( + signer, + info["name"], # service_identity_name + info["project"], + None, # audience + info["token_uri"], + info.get("ca_cert_path", None), + ) + + @classmethod + def from_service_account_info(cls, info): + """Creates a Credentials instance from parsed service account info. + + Args: + info (Mapping[str, str]): The service account info in Google + format. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.oauth2.gdch_credentials.ServiceAccountCredentials: The constructed + credentials. + + Raises: + ValueError: If the info is not in the expected format. + """ + signer = _service_account_info.from_dict( + info, + require=[ + "format_version", + "private_key_id", + "private_key", + "name", + "project", + "token_uri", + ], + use_rsa_signer=False, + ) + return cls._from_signer_and_info(signer, info) + + @classmethod + def from_service_account_file(cls, filename): + """Creates a Credentials instance from a service account json file. + + Args: + filename (str): The path to the service account json file. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.oauth2.gdch_credentials.ServiceAccountCredentials: The constructed + credentials. + """ + info, signer = _service_account_info.from_filename( + filename, + require=[ + "format_version", + "private_key_id", + "private_key", + "name", + "project", + "token_uri", + ], + use_rsa_signer=False, + ) + return cls._from_signer_and_info(signer, info) diff --git a/packages/google-auth/google/oauth2/id_token.py b/packages/google-auth/google/oauth2/id_token.py new file mode 100644 index 000000000000..a6c51ce63814 --- /dev/null +++ b/packages/google-auth/google/oauth2/id_token.py @@ -0,0 +1,370 @@ +# Copyright 2016 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. + +"""Google ID Token helpers. + +Provides support for verifying `OpenID Connect ID Tokens`_, especially ones +generated by Google infrastructure. + +To parse and verify an ID Token issued by Google's OAuth 2.0 authorization +server use :func:`verify_oauth2_token`. To verify an ID Token issued by +Firebase, use :func:`verify_firebase_token`. + +A general purpose ID Token verifier is available as :func:`verify_token`. + +Example:: + + from google.oauth2 import id_token + from google.auth.transport import requests + + request = requests.Request() + + id_info = id_token.verify_oauth2_token( + token, request, 'my-client-id.example.com') + + userid = id_info['sub'] + +By default, this will re-fetch certificates for each verification. Because +Google's public keys are only changed infrequently (on the order of once per +day), you may wish to take advantage of caching to reduce latency and the +potential for network errors. This can be accomplished using an external +library like `CacheControl`_ to create a cache-aware +:class:`google.auth.transport.Request`:: + + import cachecontrol + import google.auth.transport.requests + import requests + + session = requests.session() + cached_session = cachecontrol.CacheControl(session) + request = google.auth.transport.requests.Request(session=cached_session) + +.. _OpenID Connect ID Tokens: + http://openid.net/specs/openid-connect-core-1_0.html#IDToken +.. _CacheControl: https://cachecontrol.readthedocs.io +""" + +import http.client as http_client +import json +import os + +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" + +# The URL that provides public certificates for verifying ID tokens issued +# by Firebase and the Google APIs infrastructure +_GOOGLE_APIS_CERTS_URL = ( + "https://www.googleapis.com/robot/v1/metadata/x509" + "/securetoken@system.gserviceaccount.com" +) + +_GOOGLE_ISSUERS = ["accounts.google.com", "https://accounts.google.com"] + + +def _fetch_certs(request, certs_url): + """Fetches certificates. + + Google-style cerificate endpoints return JSON in the format of + ``{'key id': 'x509 certificate'}`` or a certificate array according + to the JWK spec (see https://tools.ietf.org/html/rfc7517). + + Args: + request (google.auth.transport.Request): The object used to make + HTTP requests. + certs_url (str): The certificate endpoint URL. + + Returns: + Mapping[str, str] | Mapping[str, list]: A mapping of public keys + in x.509 or JWK spec. + """ + response = request(certs_url, method="GET") + + if response.status != http_client.OK: + raise exceptions.TransportError( + "Could not fetch certificates at {}".format(certs_url) + ) + + return json.loads(response.data.decode("utf-8")) + + +def verify_token( + id_token, + request, + audience=None, + certs_url=_GOOGLE_OAUTH2_CERTS_URL, + clock_skew_in_seconds=0, +): + """Verifies an ID token and returns the decoded token. + + Args: + id_token (Union[str, bytes]): The encoded token. + request (google.auth.transport.Request): The object used to make + HTTP requests. + audience (str or list): The audience or audiences that this token is + intended for. If None then the audience is not verified. + certs_url (str): The URL that specifies the certificates to use to + verify the token. This URL should return JSON in the format of + ``{'key id': 'x509 certificate'}`` or a certificate array according to + the JWK spec (see https://tools.ietf.org/html/rfc7517). + clock_skew_in_seconds (int): The clock skew used for `iat` and `exp` + validation. + + Returns: + Mapping[str, Any]: The decoded token. + """ + certs = _fetch_certs(request, certs_url) + + if "keys" in certs: + try: + import jwt as jwt_lib # type: ignore + except ImportError as caught_exc: # pragma: NO COVER + raise ImportError( + "The pyjwt library is not installed, please install the pyjwt package to use the jwk certs format." + ) from caught_exc + jwks_client = jwt_lib.PyJWKClient(certs_url) + signing_key = jwks_client.get_signing_key_from_jwt(id_token) + return jwt_lib.decode( + id_token, + signing_key.key, + algorithms=[signing_key.algorithm_name], + audience=audience, + ) + else: + return jwt.decode( + id_token, + certs=certs, + audience=audience, + clock_skew_in_seconds=clock_skew_in_seconds, + ) + + +def verify_oauth2_token(id_token, request, audience=None, clock_skew_in_seconds=0): + """Verifies an ID Token issued by Google's OAuth 2.0 authorization server. + + Args: + id_token (Union[str, bytes]): The encoded token. + request (google.auth.transport.Request): The object used to make + HTTP requests. + audience (str): The audience that this token is intended for. This is + typically your application's OAuth 2.0 client ID. If None then the + audience is not verified. + clock_skew_in_seconds (int): The clock skew used for `iat` and `exp` + validation. + + Returns: + Mapping[str, Any]: The decoded token. + + Raises: + exceptions.GoogleAuthError: If the issuer is invalid. + ValueError: If token verification fails + """ + idinfo = verify_token( + id_token, + request, + audience=audience, + certs_url=_GOOGLE_OAUTH2_CERTS_URL, + clock_skew_in_seconds=clock_skew_in_seconds, + ) + + if idinfo["iss"] not in _GOOGLE_ISSUERS: + raise exceptions.GoogleAuthError( + "Wrong issuer. 'iss' should be one of the following: {}".format( + _GOOGLE_ISSUERS + ) + ) + + return idinfo + + +def verify_firebase_token(id_token, request, audience=None, clock_skew_in_seconds=0): + """Verifies an ID Token issued by Firebase Authentication. + + Args: + id_token (Union[str, bytes]): The encoded token. + request (google.auth.transport.Request): The object used to make + HTTP requests. + audience (str): The audience that this token is intended for. This is + typically your Firebase application ID. If None then the audience + is not verified. + clock_skew_in_seconds (int): The clock skew used for `iat` and `exp` + validation. + + Returns: + Mapping[str, Any]: The decoded token. + """ + return verify_token( + id_token, + request, + audience=audience, + certs_url=_GOOGLE_APIS_CERTS_URL, + clock_skew_in_seconds=clock_skew_in_seconds, + ) + + +def fetch_id_token_credentials(audience, request=None): + """Create the ID Token credentials from the current environment. + + This function acquires ID token from the environment in the following order. + See https://google.aip.dev/auth/4110. + + 1. 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. + 2. If the application is running in Compute Engine, App Engine or Cloud Run, + then the ID token are obtained from the metadata server. + 3. 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" + + # Create ID token credentials. + credentials = google.oauth2.id_token.fetch_id_token_credentials(target_audience, request=request) + + # Refresh the credential to obtain an ID token. + credentials.refresh(request) + + id_token = credentials.token + id_token_expiry = credentials.expiry + + Args: + audience (str): The audience that this ID token is intended for. + request (Optional[google.auth.transport.Request]): A callable used to make + HTTP requests. A request object will be created if not provided. + + Returns: + google.auth.credentials.Credentials: The ID token credentials. + + Raises: + ~google.auth.exceptions.DefaultCredentialsError: + If metadata server doesn't exist and no valid service account + credentials are found. + """ + # 1. Try to get credentials from the GOOGLE_APPLICATION_CREDENTIALS environment + # variable. + credentials_filename = os.environ.get(environment_vars.CREDENTIALS) + if credentials_filename: + if not ( + os.path.exists(credentials_filename) + and os.path.isfile(credentials_filename) + ): + raise exceptions.DefaultCredentialsError( + "GOOGLE_APPLICATION_CREDENTIALS path is either not found or invalid." + ) + + try: + with open(credentials_filename, "r") as f: + from google.oauth2 import service_account + + info = json.load(f) + if info.get("type") == "service_account": + return service_account.IDTokenCredentials.from_service_account_info( + info, target_audience=audience + ) + elif info.get("type") == "impersonated_service_account": + from google.auth import impersonated_credentials + + target_credentials = impersonated_credentials.Credentials.from_impersonated_service_account_info( + info + ) + + return impersonated_credentials.IDTokenCredentials( + target_credentials=target_credentials, + target_audience=audience, + include_email=True, + ) + except ValueError as caught_exc: + new_exc = exceptions.DefaultCredentialsError( + "GOOGLE_APPLICATION_CREDENTIALS is not valid service account credentials.", + caught_exc, + ) + raise new_exc from caught_exc + + # 2. 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 + from google.auth.compute_engine import _metadata + + # Create a request object if not provided. + if not request: + import google.auth.transport.requests + + request = google.auth.transport.requests.Request() + + if _metadata.ping(request): + return compute_engine.IDTokenCredentials( + request, audience, use_metadata_identity_endpoint=True + ) + except (ImportError, exceptions.TransportError): + pass + + raise exceptions.DefaultCredentialsError( + "Neither metadata server or valid service account credentials are found." + ) + + +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. + See https://google.aip.dev/auth/4110. + + 1. 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. + 2. If the application is running in Compute Engine, App Engine or Cloud Run, + then the ID token are obtained from the metadata server. + 3. 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. + """ + id_token_credentials = fetch_id_token_credentials(audience, request=request) + id_token_credentials.refresh(request) + return id_token_credentials.token diff --git a/packages/google-auth/google/oauth2/py.typed b/packages/google-auth/google/oauth2/py.typed new file mode 100644 index 000000000000..d82ed62c2f05 --- /dev/null +++ b/packages/google-auth/google/oauth2/py.typed @@ -0,0 +1,2 @@ +# Marker file for PEP 561. +# The google-oauth2 package uses inline types. diff --git a/packages/google-auth/google/oauth2/reauth.py b/packages/google-auth/google/oauth2/reauth.py new file mode 100644 index 000000000000..abf691d58a64 --- /dev/null +++ b/packages/google-auth/google/oauth2/reauth.py @@ -0,0 +1,373 @@ +# Copyright 2021 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. + +"""A module that provides functions for handling rapt authentication. + +Reauth is a process of obtaining additional authentication (such as password, +security token, etc.) while refreshing OAuth 2.0 credentials for a user. + +Credentials that use the Reauth flow must have the reauth scope, +``https://www.googleapis.com/auth/accounts.reauth``. + +This module provides a high-level function for executing the Reauth process, +:func:`refresh_grant`, and lower-level helpers for doing the individual +steps of the reauth process. + +Those steps are: + +1. Obtaining a list of challenges from the reauth server. +2. Running through each challenge and sending the result back to the reauth + server. +3. Refreshing the access token using the returned rapt token. +""" + +import sys + +from google.auth import exceptions +from google.auth import metrics +from google.oauth2 import _client +from google.oauth2 import challenges + + +_REAUTH_SCOPE = "https://www.googleapis.com/auth/accounts.reauth" +_REAUTH_API = "https://reauth.googleapis.com/v2/sessions" + +_REAUTH_NEEDED_ERROR = "invalid_grant" +_REAUTH_NEEDED_ERROR_INVALID_RAPT = "invalid_rapt" +_REAUTH_NEEDED_ERROR_RAPT_REQUIRED = "rapt_required" + +_AUTHENTICATED = "AUTHENTICATED" +_CHALLENGE_REQUIRED = "CHALLENGE_REQUIRED" +_CHALLENGE_PENDING = "CHALLENGE_PENDING" + + +# Override this global variable to set custom max number of rounds of reauth +# challenges should be run. +RUN_CHALLENGE_RETRY_LIMIT = 5 + + +def is_interactive(): + """Check if we are in an interractive environment. + + Override this function with a different logic if you are using this library + outside a CLI. + + If the rapt token needs refreshing, the user needs to answer the challenges. + If the user is not in an interractive environment, the challenges can not + be answered and we just wait for timeout for no reason. + + Returns: + bool: True if is interactive environment, False otherwise. + """ + + return sys.stdin.isatty() + + +def _get_challenges( + request, supported_challenge_types, access_token, requested_scopes=None +): + """Does initial request to reauth API to get the challenges. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + supported_challenge_types (Sequence[str]): list of challenge names + supported by the manager. + access_token (str): Access token with reauth scopes. + requested_scopes (Optional(Sequence[str])): Authorized scopes for the credentials. + + Returns: + dict: The response from the reauth API. + """ + body = {"supportedChallengeTypes": supported_challenge_types} + if requested_scopes: + body["oauthScopesForDomainPolicyLookup"] = requested_scopes + metrics_header = {metrics.API_CLIENT_HEADER: metrics.reauth_start()} + + return _client._token_endpoint_request( + request, + _REAUTH_API + ":start", + body, + access_token=access_token, + use_json=True, + headers=metrics_header, + ) + + +def _send_challenge_result( + request, session_id, challenge_id, client_input, access_token +): + """Attempt to refresh access token by sending next challenge result. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + session_id (str): session id returned by the initial reauth call. + challenge_id (str): challenge id returned by the initial reauth call. + client_input: dict with a challenge-specific client input. For example: + ``{'credential': password}`` for password challenge. + access_token (str): Access token with reauth scopes. + + Returns: + dict: The response from the reauth API. + """ + body = { + "sessionId": session_id, + "challengeId": challenge_id, + "action": "RESPOND", + "proposalResponse": client_input, + } + metrics_header = {metrics.API_CLIENT_HEADER: metrics.reauth_continue()} + + return _client._token_endpoint_request( + request, + _REAUTH_API + "/{}:continue".format(session_id), + body, + access_token=access_token, + use_json=True, + headers=metrics_header, + ) + + +def _run_next_challenge(msg, request, access_token): + """Get the next challenge from msg and run it. + + Args: + msg (dict): Reauth API response body (either from the initial request to + https://reauth.googleapis.com/v2/sessions:start or from sending the + previous challenge response to + https://reauth.googleapis.com/v2/sessions/id:continue) + request (google.auth.transport.Request): A callable used to make + HTTP requests. + access_token (str): reauth access token + + Returns: + dict: The response from the reauth API. + + Raises: + google.auth.exceptions.ReauthError: if reauth failed. + """ + for challenge in msg["challenges"]: + if challenge["status"] != "READY": + # Skip non-activated challenges. + continue + c = challenges.AVAILABLE_CHALLENGES.get(challenge["challengeType"], None) + if not c: + raise exceptions.ReauthFailError( + "Unsupported challenge type {0}. Supported types: {1}".format( + challenge["challengeType"], + ",".join(list(challenges.AVAILABLE_CHALLENGES.keys())), + ) + ) + if not c.is_locally_eligible: + raise exceptions.ReauthFailError( + "Challenge {0} is not locally eligible".format( + challenge["challengeType"] + ) + ) + client_input = c.obtain_challenge_input(challenge) + if not client_input: + return None + return _send_challenge_result( + request, + msg["sessionId"], + challenge["challengeId"], + client_input, + access_token, + ) + return None + + +def _obtain_rapt(request, access_token, requested_scopes): + """Given an http request method and reauth access token, get rapt token. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + access_token (str): reauth access token + requested_scopes (Sequence[str]): scopes required by the client application + + Returns: + str: The rapt token. + + Raises: + google.auth.exceptions.ReauthError: if reauth failed + """ + msg = _get_challenges( + request, + list(challenges.AVAILABLE_CHALLENGES.keys()), + access_token, + requested_scopes, + ) + + if msg["status"] == _AUTHENTICATED: + return msg["encodedProofOfReauthToken"] + + for _ in range(0, RUN_CHALLENGE_RETRY_LIMIT): + if not ( + msg["status"] == _CHALLENGE_REQUIRED or msg["status"] == _CHALLENGE_PENDING + ): + raise exceptions.ReauthFailError( + "Reauthentication challenge failed due to API error: {}".format( + msg["status"] + ) + ) + + if not is_interactive(): + raise exceptions.ReauthFailError( + "Reauthentication challenge could not be answered because you are not" + " in an interactive session." + ) + + msg = _run_next_challenge(msg, request, access_token) + + if not msg: + raise exceptions.ReauthFailError("Failed to obtain rapt token.") + if msg["status"] == _AUTHENTICATED: + return msg["encodedProofOfReauthToken"] + + # If we got here it means we didn't get authenticated. + raise exceptions.ReauthFailError("Failed to obtain rapt token.") + + +def get_rapt_token( + request, client_id, client_secret, refresh_token, token_uri, scopes=None +): + """Given an http request method and refresh_token, get rapt token. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + client_id (str): client id to get access token for reauth scope. + client_secret (str): client secret for the client_id + refresh_token (str): refresh token to refresh access token + token_uri (str): uri to refresh access token + scopes (Optional(Sequence[str])): scopes required by the client application + + Returns: + str: The rapt token. + Raises: + google.auth.exceptions.RefreshError: If reauth failed. + """ + sys.stderr.write("Reauthentication required.\n") + + # Get access token for reauth. + access_token, _, _, _ = _client.refresh_grant( + request=request, + client_id=client_id, + client_secret=client_secret, + refresh_token=refresh_token, + token_uri=token_uri, + scopes=[_REAUTH_SCOPE], + ) + + # Get rapt token from reauth API. + rapt_token = _obtain_rapt(request, access_token, requested_scopes=scopes) + sys.stderr.write("Reauthentication successful.\n") + + return rapt_token + + +def refresh_grant( + request, + token_uri, + refresh_token, + client_id, + client_secret, + scopes=None, + rapt_token=None, + enable_reauth_refresh=False, +): + """Implements the reauthentication flow. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + token_uri (str): The OAuth 2.0 authorizations server's token endpoint + URI. + refresh_token (str): The refresh token to use to get a new access + token. + client_id (str): The OAuth 2.0 application's client ID. + client_secret (str): The Oauth 2.0 appliaction's client secret. + scopes (Optional(Sequence[str])): Scopes to request. If present, all + scopes must be authorized for the refresh token. Useful if refresh + token has a wild card scope (e.g. + 'https://www.googleapis.com/auth/any-api'). + rapt_token (Optional(str)): The rapt token for reauth. + enable_reauth_refresh (Optional[bool]): Whether reauth refresh flow + should be used. The default value is False. This option is for + gcloud only, other users should use the default value. + + Returns: + Tuple[str, Optional[str], Optional[datetime], Mapping[str, str], str]: The + access token, new refresh token, expiration, the additional data + returned by the token endpoint, and the rapt token. + + Raises: + google.auth.exceptions.RefreshError: If the token endpoint returned + an error. + """ + body = { + "grant_type": _client._REFRESH_GRANT_TYPE, + "client_id": client_id, + "client_secret": client_secret, + "refresh_token": refresh_token, + } + if scopes: + body["scope"] = " ".join(scopes) + if rapt_token: + body["rapt"] = rapt_token + metrics_header = {metrics.API_CLIENT_HEADER: metrics.token_request_user()} + + ( + response_status_ok, + response_data, + retryable_error, + ) = _client._token_endpoint_request_no_throw( + request, token_uri, body, headers=metrics_header + ) + + if not response_status_ok and isinstance(response_data, str): + raise exceptions.RefreshError(response_data, retryable=False) + + if ( + not response_status_ok + and response_data.get("error") == _REAUTH_NEEDED_ERROR + and ( + response_data.get("error_subtype") == _REAUTH_NEEDED_ERROR_INVALID_RAPT + or response_data.get("error_subtype") == _REAUTH_NEEDED_ERROR_RAPT_REQUIRED + ) + ): + if not enable_reauth_refresh: + raise exceptions.RefreshError( + "Reauthentication is needed. Please run `gcloud auth application-default login` to reauthenticate." + ) + + rapt_token = get_rapt_token( + request, client_id, client_secret, refresh_token, token_uri, scopes=scopes + ) + body["rapt"] = rapt_token + ( + response_status_ok, + response_data, + retryable_error, + ) = _client._token_endpoint_request_no_throw( + request, token_uri, body, headers=metrics_header + ) + + if not response_status_ok: + _client._handle_error_response(response_data, retryable_error) + return _client._handle_refresh_grant_response(response_data, refresh_token) + ( + rapt_token, + ) diff --git a/packages/google-auth/google/oauth2/service_account.py b/packages/google-auth/google/oauth2/service_account.py new file mode 100644 index 000000000000..8ac589d10984 --- /dev/null +++ b/packages/google-auth/google/oauth2/service_account.py @@ -0,0 +1,880 @@ +# Copyright 2016 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. + +"""Service Accounts: JSON Web Token (JWT) Profile for OAuth 2.0 + +This module implements the JWT Profile for OAuth 2.0 Authorization Grants +as defined by `RFC 7523`_ with particular support for how this RFC is +implemented in Google's infrastructure. Google refers to these credentials +as *Service Accounts*. + +Service accounts are used for server-to-server communication, such as +interactions between a web application server and a Google service. The +service account belongs to your application instead of to an individual end +user. In contrast to other OAuth 2.0 profiles, no users are involved and your +application "acts" as the service account. + +Typically an application uses a service account when the application uses +Google APIs to work with its own data rather than a user's data. For example, +an application that uses Google Cloud Datastore for data persistence would use +a service account to authenticate its calls to the Google Cloud Datastore API. +However, an application that needs to access a user's Drive documents would +use the normal OAuth 2.0 profile. + +Additionally, Google Apps domain administrators can grant service accounts +`domain-wide delegation`_ authority to access user data on behalf of users in +the domain. + +This profile uses a JWT to acquire an OAuth 2.0 access token. The JWT is used +in place of the usual authorization token returned during the standard +OAuth 2.0 Authorization Code grant. The JWT is only used for this purpose, as +the acquired access token is used as the bearer token when making requests +using these credentials. + +This profile differs from normal OAuth 2.0 profile because no user consent +step is required. The use of the private key allows this profile to assert +identity directly. + +This profile also differs from the :mod:`google.auth.jwt` authentication +because the JWT credentials use the JWT directly as the bearer token. This +profile instead only uses the JWT to obtain an OAuth 2.0 access token. The +obtained OAuth 2.0 access token is used as the bearer token. + +Domain-wide delegation +---------------------- + +Domain-wide delegation allows a service account to access user data on +behalf of any user in a Google Apps domain without consent from the user. +For example, an application that uses the Google Calendar API to add events to +the calendars of all users in a Google Apps domain would use a service account +to access the Google Calendar API on behalf of users. + +The Google Apps administrator must explicitly authorize the service account to +do this. This authorization step is referred to as "delegating domain-wide +authority" to a service account. + +You can use domain-wise delegation by creating a set of credentials with a +specific subject using :meth:`~Credentials.with_subject`. + +.. _RFC 7523: https://tools.ietf.org/html/rfc7523 +""" + +import copy +import datetime + +from google.auth import _constants +from google.auth import _helpers +from google.auth import _service_account_info +from google.auth import credentials +from google.auth import exceptions +from google.auth import iam +from google.auth import jwt +from google.auth import metrics +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( + credentials.Signing, + credentials.Scoped, + credentials.CredentialsWithQuotaProject, + credentials.CredentialsWithTokenUri, + credentials.CredentialsWithTrustBoundary, +): + """Service account credentials + + Usually, you'll create these credentials with one of the helper + constructors. To create credentials using a Google service account + private key JSON file:: + + credentials = service_account.Credentials.from_service_account_file( + 'service-account.json') + + Or if you already have the service account file loaded:: + + service_account_info = json.load(open('service_account.json')) + credentials = service_account.Credentials.from_service_account_info( + service_account_info) + + Both helper methods pass on arguments to the constructor, so you can + specify additional scopes and a subject if necessary:: + + credentials = service_account.Credentials.from_service_account_file( + 'service-account.json', + scopes=['email'], + subject='user@example.com') + + The credentials are considered immutable. If you want to modify the scopes + or the subject used for delegation, use :meth:`with_scopes` or + :meth:`with_subject`:: + + scoped_credentials = credentials.with_scopes(['email']) + delegated_credentials = credentials.with_subject(subject) + + To add a quota project, use :meth:`with_quota_project`:: + + credentials = credentials.with_quota_project('myproject-123') + """ + + def __init__( + self, + signer, + service_account_email, + token_uri, + scopes=None, + default_scopes=None, + subject=None, + project_id=None, + quota_project_id=None, + additional_claims=None, + always_use_jwt_access=False, + universe_domain=credentials.DEFAULT_UNIVERSE_DOMAIN, + trust_boundary=None, + ): + """ + Args: + signer (google.auth.crypt.Signer): The signer used to sign JWTs. + service_account_email (str): The service account's email. + scopes (Sequence[str]): User-defined scopes to request during the + authorization grant. + default_scopes (Sequence[str]): Default scopes passed by a + Google client library. Use 'scopes' for user-defined scopes. + token_uri (str): The OAuth 2.0 Token URI. + subject (str): For domain-wide delegation, the email address of the + user to for which to request delegated access. + project_id (str): Project ID associated with the service account + credential. + quota_project_id (Optional[str]): The project ID used for quota and + billing. + additional_claims (Mapping[str, str]): Any additional claims for + the JWT assertion used in the authorization grant. + always_use_jwt_access (Optional[bool]): Whether self signed JWT should + be always used. + universe_domain (str): The universe domain. The default + universe domain is googleapis.com. For default value self + signed jwt is used for token refresh. + trust_boundary (Mapping[str,str]): A credential trust boundary. + + .. note:: Typically one of the helper constructors + :meth:`from_service_account_file` or + :meth:`from_service_account_info` are used instead of calling the + constructor directly. + """ + super(Credentials, self).__init__() + + self._cred_file_path = None + self._scopes = scopes + self._default_scopes = default_scopes + self._signer = signer + self._service_account_email = service_account_email + self._subject = subject + self._project_id = project_id + self._quota_project_id = quota_project_id + self._token_uri = token_uri + self._always_use_jwt_access = always_use_jwt_access + self._universe_domain = universe_domain or credentials.DEFAULT_UNIVERSE_DOMAIN + + if universe_domain != credentials.DEFAULT_UNIVERSE_DOMAIN: + self._always_use_jwt_access = True + + self._jwt_credentials = None + + if additional_claims is not None: + self._additional_claims = additional_claims + else: + self._additional_claims = {} + self._trust_boundary = trust_boundary + + @classmethod + def _from_signer_and_info(cls, signer, info, **kwargs): + """Creates a Credentials instance from a signer and service account + info. + + Args: + signer (google.auth.crypt.Signer): The signer used to sign JWTs. + info (Mapping[str, str]): The service account info. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.jwt.Credentials: The constructed credentials. + + Raises: + ValueError: If the info is not in the expected format. + """ + return cls( + signer, + service_account_email=info["client_email"], + token_uri=info["token_uri"], + project_id=info.get("project_id"), + universe_domain=info.get( + "universe_domain", credentials.DEFAULT_UNIVERSE_DOMAIN + ), + trust_boundary=info.get("trust_boundary"), + **kwargs, + ) + + @classmethod + def from_service_account_info(cls, info, **kwargs): + """Creates a Credentials instance from parsed service account info. + + Args: + info (Mapping[str, str]): The service account info in Google + format. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.service_account.Credentials: The constructed + credentials. + + Raises: + ValueError: If the info is not in the expected format. + """ + signer = _service_account_info.from_dict( + info, require=["client_email", "token_uri"] + ) + return cls._from_signer_and_info(signer, info, **kwargs) + + @classmethod + def from_service_account_file(cls, filename, **kwargs): + """Creates a Credentials instance from a service account json file. + + Args: + filename (str): The path to the service account json file. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.service_account.Credentials: The constructed + credentials. + """ + info, signer = _service_account_info.from_filename( + filename, require=["client_email", "token_uri"] + ) + return cls._from_signer_and_info(signer, info, **kwargs) + + @property + def service_account_email(self): + """The service account email.""" + return self._service_account_email + + @property + def project_id(self): + """Project ID associated with this credential.""" + return self._project_id + + @property + def requires_scopes(self): + """Checks if the credentials requires scopes. + + Returns: + bool: True if there are no scopes set otherwise False. + """ + return True if not self._scopes else False + + def _make_copy(self): + cred = self.__class__( + self._signer, + service_account_email=self._service_account_email, + scopes=copy.copy(self._scopes), + default_scopes=copy.copy(self._default_scopes), + token_uri=self._token_uri, + subject=self._subject, + project_id=self._project_id, + quota_project_id=self._quota_project_id, + additional_claims=self._additional_claims.copy(), + always_use_jwt_access=self._always_use_jwt_access, + universe_domain=self._universe_domain, + trust_boundary=self._trust_boundary, + ) + cred._cred_file_path = self._cred_file_path + return cred + + @_helpers.copy_docstring(credentials.Scoped) + def with_scopes(self, scopes, default_scopes=None): + cred = self._make_copy() + cred._scopes = scopes + cred._default_scopes = default_scopes + return cred + + def with_always_use_jwt_access(self, always_use_jwt_access): + """Create a copy of these credentials with the specified always_use_jwt_access value. + + Args: + always_use_jwt_access (bool): Whether always use self signed JWT or not. + + Returns: + google.auth.service_account.Credentials: A new credentials + instance. + Raises: + google.auth.exceptions.InvalidValue: If the universe domain is not + default and always_use_jwt_access is False. + """ + cred = self._make_copy() + if ( + cred._universe_domain != credentials.DEFAULT_UNIVERSE_DOMAIN + and not always_use_jwt_access + ): + raise exceptions.InvalidValue( + "always_use_jwt_access should be True for non-default universe domain" + ) + cred._always_use_jwt_access = always_use_jwt_access + return cred + + @_helpers.copy_docstring(credentials.CredentialsWithUniverseDomain) + def with_universe_domain(self, universe_domain): + cred = self._make_copy() + cred._universe_domain = universe_domain + if universe_domain != credentials.DEFAULT_UNIVERSE_DOMAIN: + cred._always_use_jwt_access = True + return cred + + def with_subject(self, subject): + """Create a copy of these credentials with the specified subject. + + Args: + subject (str): The subject claim. + + Returns: + google.auth.service_account.Credentials: A new credentials + instance. + """ + cred = self._make_copy() + cred._subject = subject + return cred + + def with_claims(self, additional_claims): + """Returns a copy of these credentials with modified claims. + + Args: + additional_claims (Mapping[str, str]): Any additional claims for + the JWT payload. This will be merged with the current + additional claims. + + Returns: + google.auth.service_account.Credentials: A new credentials + instance. + """ + new_additional_claims = copy.deepcopy(self._additional_claims) + new_additional_claims.update(additional_claims or {}) + cred = self._make_copy() + cred._additional_claims = new_additional_claims + return cred + + @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) + def with_quota_project(self, quota_project_id): + cred = self._make_copy() + cred._quota_project_id = quota_project_id + return cred + + @_helpers.copy_docstring(credentials.CredentialsWithTokenUri) + def with_token_uri(self, token_uri): + cred = self._make_copy() + cred._token_uri = token_uri + return cred + + @_helpers.copy_docstring(credentials.CredentialsWithTrustBoundary) + def with_trust_boundary(self, trust_boundary): + cred = self._make_copy() + cred._trust_boundary = trust_boundary + return cred + + def _make_authorization_grant_assertion(self): + """Create the OAuth 2.0 assertion. + + This assertion is used during the OAuth 2.0 grant to acquire an + access token. + + Returns: + bytes: The authorization grant assertion. + """ + now = _helpers.utcnow() + lifetime = datetime.timedelta(seconds=_DEFAULT_TOKEN_LIFETIME_SECS) + expiry = now + lifetime + + payload = { + "iat": _helpers.datetime_to_secs(now), + "exp": _helpers.datetime_to_secs(expiry), + # 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, + "scope": _helpers.scopes_to_string(self._scopes or ()), + } + + payload.update(self._additional_claims) + + # The subject can be a user email for domain-wide delegation. + if self._subject: + payload.setdefault("sub", self._subject) + + token = jwt.encode(self._signer, payload) + + return token + + def _use_self_signed_jwt(self): + # Since domain wide delegation doesn't work with self signed JWT. If + # subject exists, then we should not use self signed JWT. + return self._subject is None and self._jwt_credentials is not None + + def _metric_header_for_usage(self): + if self._use_self_signed_jwt(): + return metrics.CRED_TYPE_SA_JWT + return metrics.CRED_TYPE_SA_ASSERTION + + @_helpers.copy_docstring(credentials.CredentialsWithTrustBoundary) + def _refresh_token(self, request): + if self._always_use_jwt_access and not self._jwt_credentials: + # If self signed jwt should be used but jwt credential is not + # created, try to create one with scopes + self._create_self_signed_jwt(None) + + if ( + self._universe_domain != credentials.DEFAULT_UNIVERSE_DOMAIN + and self._subject + ): + raise exceptions.RefreshError( + "domain wide delegation is not supported for non-default universe domain" + ) + + if self._use_self_signed_jwt(): + self._jwt_credentials.refresh(request) + self.token = self._jwt_credentials.token.decode() + self.expiry = self._jwt_credentials.expiry + else: + assertion = self._make_authorization_grant_assertion() + access_token, expiry, _ = _client.jwt_grant( + request, self._token_uri, assertion + ) + self.token = access_token + self.expiry = expiry + + def _create_self_signed_jwt(self, audience): + """Create a self-signed JWT from the credentials if requirements are met. + + Args: + audience (str): The service URL. ``https://[API_ENDPOINT]/`` + """ + # https://google.aip.dev/auth/4111 + if self._always_use_jwt_access: + if self._scopes: + additional_claims = {"scope": " ".join(self._scopes)} + if ( + self._jwt_credentials is None + or self._jwt_credentials.additional_claims != additional_claims + ): + self._jwt_credentials = jwt.Credentials.from_signing_credentials( + self, None, additional_claims=additional_claims + ) + elif audience: + if ( + self._jwt_credentials is None + or self._jwt_credentials._audience != audience + ): + self._jwt_credentials = jwt.Credentials.from_signing_credentials( + self, audience + ) + elif self._default_scopes: + additional_claims = {"scope": " ".join(self._default_scopes)} + if ( + self._jwt_credentials is None + or additional_claims != self._jwt_credentials.additional_claims + ): + self._jwt_credentials = jwt.Credentials.from_signing_credentials( + self, None, additional_claims=additional_claims + ) + elif not self._scopes and audience: + self._jwt_credentials = jwt.Credentials.from_signing_credentials( + self, audience + ) + + def _build_trust_boundary_lookup_url(self): + """Builds and returns the URL for the trust boundary lookup API. + + This method constructs the specific URL for the IAM Credentials API's + `allowedLocations` endpoint, using the credential's universe domain + and service account email. + + Raises: + ValueError: If `self.service_account_email` is None or an empty + string, as it's required to form the URL. + + Returns: + str: The URL for the trust boundary lookup endpoint. + """ + if not self.service_account_email: + raise ValueError( + "Service account email is required to build the trust boundary lookup URL." + ) + return _constants._SERVICE_ACCOUNT_TRUST_BOUNDARY_LOOKUP_ENDPOINT.format( + universe_domain=self._universe_domain, + service_account_email=self._service_account_email, + ) + + @_helpers.copy_docstring(credentials.Signing) + def sign_bytes(self, message): + return self._signer.sign(message) + + @property # type: ignore + @_helpers.copy_docstring(credentials.Signing) + def signer(self): + return self._signer + + @property # type: ignore + @_helpers.copy_docstring(credentials.Signing) + def signer_email(self): + return self._service_account_email + + @_helpers.copy_docstring(credentials.Credentials) + def get_cred_info(self): + if self._cred_file_path: + return { + "credential_source": self._cred_file_path, + "credential_type": "service account credentials", + "principal": self.service_account_email, + } + return None + + +class IDTokenCredentials( + credentials.Signing, + credentials.CredentialsWithQuotaProject, + credentials.CredentialsWithTokenUri, +): + """Open ID Connect ID Token-based service account credentials. + + These credentials are largely similar to :class:`.Credentials`, but instead + of using an OAuth 2.0 Access Token as the bearer token, they use an Open + ID Connect ID Token as the bearer token. These credentials are useful when + communicating to services that require ID Tokens and can not accept access + tokens. + + Usually, you'll create these credentials with one of the helper + constructors. To create credentials using a Google service account + private key JSON file:: + + credentials = ( + service_account.IDTokenCredentials.from_service_account_file( + 'service-account.json')) + + + Or if you already have the service account file loaded:: + + service_account_info = json.load(open('service_account.json')) + credentials = ( + service_account.IDTokenCredentials.from_service_account_info( + service_account_info)) + + + Both helper methods pass on arguments to the constructor, so you can + specify additional scopes and a subject if necessary:: + + credentials = ( + service_account.IDTokenCredentials.from_service_account_file( + 'service-account.json', + scopes=['email'], + subject='user@example.com')) + + + The credentials are considered immutable. If you want to modify the scopes + or the subject used for delegation, use :meth:`with_scopes` or + :meth:`with_subject`:: + + scoped_credentials = credentials.with_scopes(['email']) + delegated_credentials = credentials.with_subject(subject) + + """ + + def __init__( + self, + signer, + service_account_email, + token_uri, + target_audience, + additional_claims=None, + quota_project_id=None, + universe_domain=credentials.DEFAULT_UNIVERSE_DOMAIN, + ): + """ + Args: + signer (google.auth.crypt.Signer): The signer used to sign JWTs. + service_account_email (str): The service account's email. + token_uri (str): The OAuth 2.0 Token URI. + target_audience (str): The intended audience for these credentials, + used when requesting the ID Token. The ID Token's ``aud`` claim + will be set to this string. + additional_claims (Mapping[str, str]): Any additional claims for + the JWT assertion used in the authorization grant. + quota_project_id (Optional[str]): The project ID used for quota and billing. + universe_domain (str): The universe domain. The default + universe domain is googleapis.com. For default value IAM ID + token endponint is used for token refresh. Note that + iam.serviceAccountTokenCreator role is required to use the IAM + endpoint. + + .. note:: Typically one of the helper constructors + :meth:`from_service_account_file` or + :meth:`from_service_account_info` are used instead of calling the + constructor directly. + """ + super(IDTokenCredentials, self).__init__() + self._signer = signer + self._service_account_email = service_account_email + self._token_uri = token_uri + self._target_audience = target_audience + self._quota_project_id = quota_project_id + self._use_iam_endpoint = False + + if not universe_domain: + self._universe_domain = credentials.DEFAULT_UNIVERSE_DOMAIN + else: + self._universe_domain = universe_domain + self._iam_id_token_endpoint = iam._IAM_IDTOKEN_ENDPOINT.replace( + "googleapis.com", self._universe_domain + ) + + if self._universe_domain != credentials.DEFAULT_UNIVERSE_DOMAIN: + self._use_iam_endpoint = True + + if additional_claims is not None: + self._additional_claims = additional_claims + else: + self._additional_claims = {} + + @classmethod + def _from_signer_and_info(cls, signer, info, **kwargs): + """Creates a credentials instance from a signer and service account + info. + + Args: + signer (google.auth.crypt.Signer): The signer used to sign JWTs. + info (Mapping[str, str]): The service account info. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.jwt.IDTokenCredentials: The constructed credentials. + + Raises: + ValueError: If the info is not in the expected format. + """ + kwargs.setdefault("service_account_email", info["client_email"]) + kwargs.setdefault("token_uri", info["token_uri"]) + if "universe_domain" in info: + kwargs["universe_domain"] = info["universe_domain"] + return cls(signer, **kwargs) + + @classmethod + def from_service_account_info(cls, info, **kwargs): + """Creates a credentials instance from parsed service account info. + + Args: + info (Mapping[str, str]): The service account info in Google + format. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.service_account.IDTokenCredentials: The constructed + credentials. + + Raises: + ValueError: If the info is not in the expected format. + """ + signer = _service_account_info.from_dict( + info, require=["client_email", "token_uri"] + ) + return cls._from_signer_and_info(signer, info, **kwargs) + + @classmethod + def from_service_account_file(cls, filename, **kwargs): + """Creates a credentials instance from a service account json file. + + Args: + filename (str): The path to the service account json file. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.service_account.IDTokenCredentials: The constructed + credentials. + """ + info, signer = _service_account_info.from_filename( + filename, require=["client_email", "token_uri"] + ) + return cls._from_signer_and_info(signer, info, **kwargs) + + def _make_copy(self): + 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, + universe_domain=self._universe_domain, + ) + # _use_iam_endpoint is not exposed in the constructor + cred._use_iam_endpoint = self._use_iam_endpoint + return cred + + def with_target_audience(self, target_audience): + """Create a copy of these credentials with the specified target + audience. + + Args: + target_audience (str): The intended audience for these credentials, + used when requesting the ID Token. + + Returns: + google.auth.service_account.IDTokenCredentials: A new credentials + instance. + """ + cred = self._make_copy() + cred._target_audience = target_audience + return cred + + 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. + Raises: + google.auth.exceptions.InvalidValue: If the universe domain is not + default and use_iam_endpoint is False. + """ + cred = self._make_copy() + if ( + cred._universe_domain != credentials.DEFAULT_UNIVERSE_DOMAIN + and not use_iam_endpoint + ): + raise exceptions.InvalidValue( + "use_iam_endpoint should be True for non-default universe domain" + ) + cred._use_iam_endpoint = use_iam_endpoint + return cred + + @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) + def with_quota_project(self, quota_project_id): + cred = self._make_copy() + cred._quota_project_id = quota_project_id + return cred + + @_helpers.copy_docstring(credentials.CredentialsWithTokenUri) + def with_token_uri(self, token_uri): + cred = self._make_copy() + cred._token_uri = token_uri + return cred + + def _make_authorization_grant_assertion(self): + """Create the OAuth 2.0 assertion. + + This assertion is used during the OAuth 2.0 grant to acquire an + ID token. + + Returns: + bytes: The authorization grant assertion. + """ + now = _helpers.utcnow() + lifetime = datetime.timedelta(seconds=_DEFAULT_TOKEN_LIFETIME_SECS) + expiry = now + lifetime + + payload = { + "iat": _helpers.datetime_to_secs(now), + "exp": _helpers.datetime_to_secs(expiry), + # 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, + # The target audience specifies which service the ID token is + # intended for. + "target_audience": self._target_audience, + } + + payload.update(self._additional_claims) + + token = jwt.encode(self._signer, payload) + + 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", + "useEmailAzp": "true", + } + + 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._iam_id_token_endpoint, + self.signer_email, + self._target_audience, + jwt_credentials.token.decode(), + self._universe_domain, + ) + + @_helpers.copy_docstring(credentials.Credentials) + def refresh(self, request): + 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): + """The service account email.""" + return self._service_account_email + + @_helpers.copy_docstring(credentials.Signing) + def sign_bytes(self, message): + return self._signer.sign(message) + + @property # type: ignore + @_helpers.copy_docstring(credentials.Signing) + def signer(self): + return self._signer + + @property # type: ignore + @_helpers.copy_docstring(credentials.Signing) + def signer_email(self): + return self._service_account_email diff --git a/packages/google-auth/google/oauth2/sts.py b/packages/google-auth/google/oauth2/sts.py new file mode 100644 index 000000000000..60d6f83c4d9f --- /dev/null +++ b/packages/google-auth/google/oauth2/sts.py @@ -0,0 +1,201 @@ +# 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. + +"""OAuth 2.0 Token Exchange Spec. + +This module defines a token exchange utility based on the `OAuth 2.0 Token +Exchange`_ spec. This will be mainly used to exchange external credentials +for GCP access tokens in workload identity pools to access Google APIs. + +The implementation will support various types of client authentication as +allowed in the spec. + +A deviation on the spec will be for additional Google specific options that +cannot be easily mapped to parameters defined in the RFC. + +The returned dictionary response will be based on the `rfc8693 section 2.2.1`_ +spec JSON response. + +.. _OAuth 2.0 Token Exchange: https://tools.ietf.org/html/rfc8693 +.. _rfc8693 section 2.2.1: https://tools.ietf.org/html/rfc8693#section-2.2.1 +""" + +import http.client as http_client +import json +import urllib + +from google.oauth2 import utils + + +_URLENCODED_HEADERS = {"Content-Type": "application/x-www-form-urlencoded"} + + +class Client(utils.OAuthClientAuthHandler): + """Implements the OAuth 2.0 token exchange spec based on + https://tools.ietf.org/html/rfc8693. + """ + + def __init__(self, token_exchange_endpoint, client_authentication=None): + """Initializes an STS client instance. + + Args: + token_exchange_endpoint (str): The token exchange endpoint. + client_authentication (Optional(google.oauth2.oauth2_utils.ClientAuthentication)): + The optional OAuth client authentication credentials if available. + """ + super(Client, self).__init__(client_authentication) + self._token_exchange_endpoint = token_exchange_endpoint + + def _make_request(self, request, headers, request_body, url=None): + # Initialize request headers. + request_headers = _URLENCODED_HEADERS.copy() + + # Inject additional headers. + if headers: + for k, v in dict(headers).items(): + request_headers[k] = v + + # Apply OAuth client authentication. + self.apply_client_authentication_options(request_headers, request_body) + + # Use default token exchange endpoint if no url is provided. + url = url or self._token_exchange_endpoint + + # Execute request. + response = request( + url=url, + method="POST", + headers=request_headers, + body=urllib.parse.urlencode(request_body).encode("utf-8"), + ) + + response_body = ( + response.data.decode("utf-8") + if hasattr(response.data, "decode") + else response.data + ) + + # If non-200 response received, translate to OAuthError exception. + if response.status != http_client.OK: + utils.handle_error_response(response_body) + + # A successful token revocation returns an empty response body. + if not response_body: + return {} + + # Other successful responses should be valid JSON. + return json.loads(response_body) + + def exchange_token( + self, + request, + grant_type, + subject_token, + subject_token_type, + resource=None, + audience=None, + scopes=None, + requested_token_type=None, + actor_token=None, + actor_token_type=None, + additional_options=None, + additional_headers=None, + ): + """Exchanges the provided token for another type of token based on the + rfc8693 spec. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + grant_type (str): The OAuth 2.0 token exchange grant type. + subject_token (str): The OAuth 2.0 token exchange subject token. + subject_token_type (str): The OAuth 2.0 token exchange subject token type. + resource (Optional[str]): The optional OAuth 2.0 token exchange resource field. + audience (Optional[str]): The optional OAuth 2.0 token exchange audience field. + scopes (Optional[Sequence[str]]): The optional list of scopes to use. + requested_token_type (Optional[str]): The optional OAuth 2.0 token exchange requested + token type. + actor_token (Optional[str]): The optional OAuth 2.0 token exchange actor token. + actor_token_type (Optional[str]): The optional OAuth 2.0 token exchange actor token type. + additional_options (Optional[Mapping[str, str]]): The optional additional + non-standard Google specific options. + additional_headers (Optional[Mapping[str, str]]): The optional additional + headers to pass to the token exchange endpoint. + + Returns: + Mapping[str, str]: The token exchange JSON-decoded response data containing + the requested token and its expiration time. + + Raises: + google.auth.exceptions.OAuthError: If the token endpoint returned + an error. + """ + # Initialize request body. + request_body = { + "grant_type": grant_type, + "resource": resource, + "audience": audience, + "scope": " ".join(scopes or []), + "requested_token_type": requested_token_type, + "subject_token": subject_token, + "subject_token_type": subject_token_type, + "actor_token": actor_token, + "actor_token_type": actor_token_type, + "options": None, + } + # Add additional non-standard options. + if additional_options: + request_body["options"] = urllib.parse.quote(json.dumps(additional_options)) + # Remove empty fields in request body. + for k, v in dict(request_body).items(): + if v is None or v == "": + del request_body[k] + + return self._make_request(request, additional_headers, request_body) + + def refresh_token(self, request, refresh_token): + """Exchanges a refresh token for an access token based on the + RFC6749 spec. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + subject_token (str): The OAuth 2.0 refresh token. + """ + + return self._make_request( + request, + None, + {"grant_type": "refresh_token", "refresh_token": refresh_token}, + ) + + def revoke_token(self, request, token, token_type_hint, revoke_url): + """Revokes the provided token based on the RFC7009 spec. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + token (str): The OAuth 2.0 token to revoke. + token_type_hint (str): Hint for the type of token being revoked. + revoke_url (str): The STS endpoint URL for revoking tokens. + + Raises: + google.auth.exceptions.OAuthError: If the token revocation endpoint + returned an error. + """ + request_body = {"token": token} + if token_type_hint: + request_body["token_type_hint"] = token_type_hint + + return self._make_request(request, None, request_body, revoke_url) diff --git a/packages/google-auth/google/oauth2/utils.py b/packages/google-auth/google/oauth2/utils.py new file mode 100644 index 000000000000..d72ff1916631 --- /dev/null +++ b/packages/google-auth/google/oauth2/utils.py @@ -0,0 +1,168 @@ +# 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. + +"""OAuth 2.0 Utilities. + +This module provides implementations for various OAuth 2.0 utilities. +This includes `OAuth error handling`_ and +`Client authentication for OAuth flows`_. + +OAuth error handling +-------------------- +This will define interfaces for handling OAuth related error responses as +stated in `RFC 6749 section 5.2`_. +This will include a common function to convert these HTTP error responses to a +:class:`google.auth.exceptions.OAuthError` exception. + + +Client authentication for OAuth flows +------------------------------------- +We introduce an interface for defining client authentication credentials based +on `RFC 6749 section 2.3.1`_. This will expose the following +capabilities: + + * Ability to support basic authentication via request header. + * Ability to support bearer token authentication via request header. + * Ability to support client ID / secret authentication via request body. + +.. _RFC 6749 section 2.3.1: https://tools.ietf.org/html/rfc6749#section-2.3.1 +.. _RFC 6749 section 5.2: https://tools.ietf.org/html/rfc6749#section-5.2 +""" + +import abc +import base64 +import enum +import json + +from google.auth import exceptions + + +# OAuth client authentication based on +# https://tools.ietf.org/html/rfc6749#section-2.3. +class ClientAuthType(enum.Enum): + basic = 1 + request_body = 2 + + +class ClientAuthentication(object): + """Defines the client authentication credentials for basic and request-body + types based on https://tools.ietf.org/html/rfc6749#section-2.3.1. + """ + + def __init__(self, client_auth_type, client_id, client_secret=None): + """Instantiates a client authentication object containing the client ID + and secret credentials for basic and response-body auth. + + Args: + client_auth_type (google.oauth2.oauth_utils.ClientAuthType): The + client authentication type. + client_id (str): The client ID. + client_secret (Optional[str]): The client secret. + """ + self.client_auth_type = client_auth_type + self.client_id = client_id + self.client_secret = client_secret + + +class OAuthClientAuthHandler(metaclass=abc.ABCMeta): + """Abstract class for handling client authentication in OAuth-based + operations. + """ + + def __init__(self, client_authentication=None): + """Instantiates an OAuth client authentication handler. + + Args: + client_authentication (Optional[google.oauth2.utils.ClientAuthentication]): + The OAuth client authentication credentials if available. + """ + super(OAuthClientAuthHandler, self).__init__() + self._client_authentication = client_authentication + + def apply_client_authentication_options( + self, headers, request_body=None, bearer_token=None + ): + """Applies client authentication on the OAuth request's headers or POST + body. + + Args: + headers (Mapping[str, str]): The HTTP request header. + request_body (Optional[Mapping[str, str]]): The HTTP request body + dictionary. For requests that do not support request body, this + is None and will be ignored. + bearer_token (Optional[str]): The optional bearer token. + """ + # Inject authenticated header. + self._inject_authenticated_headers(headers, bearer_token) + # Inject authenticated request body. + if bearer_token is None: + self._inject_authenticated_request_body(request_body) + + def _inject_authenticated_headers(self, headers, bearer_token=None): + if bearer_token is not None: + headers["Authorization"] = "Bearer %s" % bearer_token + elif ( + self._client_authentication is not None + and self._client_authentication.client_auth_type is ClientAuthType.basic + ): + username = self._client_authentication.client_id + password = self._client_authentication.client_secret or "" + + credentials = base64.b64encode( + ("%s:%s" % (username, password)).encode() + ).decode() + headers["Authorization"] = "Basic %s" % credentials + + def _inject_authenticated_request_body(self, request_body): + if ( + self._client_authentication is not None + and self._client_authentication.client_auth_type + is ClientAuthType.request_body + ): + if request_body is None: + raise exceptions.OAuthError( + "HTTP request does not support request-body" + ) + else: + request_body["client_id"] = self._client_authentication.client_id + request_body["client_secret"] = ( + self._client_authentication.client_secret or "" + ) + + +def handle_error_response(response_body): + """Translates an error response from an OAuth operation into an + OAuthError exception. + + Args: + response_body (str): The decoded response data. + + Raises: + google.auth.exceptions.OAuthError + """ + try: + error_components = [] + error_data = json.loads(response_body) + + error_components.append("Error code {}".format(error_data["error"])) + if "error_description" in error_data: + error_components.append(": {}".format(error_data["error_description"])) + if "error_uri" in error_data: + error_components.append(" - {}".format(error_data["error_uri"])) + error_details = "".join(error_components) + # If no details could be extracted, use the response data. + except (KeyError, ValueError): + error_details = response_body + + raise exceptions.OAuthError(error_details, response_body) diff --git a/packages/google-auth/google/oauth2/webauthn_handler.py b/packages/google-auth/google/oauth2/webauthn_handler.py new file mode 100644 index 000000000000..e27c7e099005 --- /dev/null +++ b/packages/google-auth/google/oauth2/webauthn_handler.py @@ -0,0 +1,82 @@ +import abc +import os +import struct +import subprocess + +from google.auth import exceptions +from google.oauth2.webauthn_types import GetRequest, GetResponse + + +class WebAuthnHandler(abc.ABC): + @abc.abstractmethod + def is_available(self) -> bool: + """Check whether this WebAuthn handler is available""" + raise NotImplementedError("is_available method must be implemented") + + @abc.abstractmethod + def get(self, get_request: GetRequest) -> GetResponse: + """WebAuthn get (assertion)""" + raise NotImplementedError("get method must be implemented") + + +class PluginHandler(WebAuthnHandler): + """Offloads WebAuthn get reqeust to a pluggable command-line tool. + + Offloads WebAuthn get to a plugin which takes the form of a + command-line tool. The command-line tool is configurable via the + PluginHandler._ENV_VAR environment variable. + + The WebAuthn plugin should implement the following interface: + + Communication occurs over stdin/stdout, and messages are both sent and + received in the form: + + [4 bytes - payload size (little-endian)][variable bytes - json payload] + """ + + _ENV_VAR = "GOOGLE_AUTH_WEBAUTHN_PLUGIN" + + def is_available(self) -> bool: + try: + self._find_plugin() + except Exception: + return False + else: + return True + + def get(self, get_request: GetRequest) -> GetResponse: + request_json = get_request.to_json() + cmd = self._find_plugin() + response_json = self._call_plugin(cmd, request_json) + return GetResponse.from_json(response_json) + + def _call_plugin(self, cmd: str, input_json: str) -> str: + # Calculate length of input + input_length = len(input_json) + length_bytes_le = struct.pack(" str: + plugin_cmd = os.environ.get(PluginHandler._ENV_VAR) + if plugin_cmd is None: + raise exceptions.InvalidResource( + "{} env var is not set".format(PluginHandler._ENV_VAR) + ) + return plugin_cmd diff --git a/packages/google-auth/google/oauth2/webauthn_handler_factory.py b/packages/google-auth/google/oauth2/webauthn_handler_factory.py new file mode 100644 index 000000000000..184329fed7e9 --- /dev/null +++ b/packages/google-auth/google/oauth2/webauthn_handler_factory.py @@ -0,0 +1,16 @@ +from typing import List, Optional + +from google.oauth2.webauthn_handler import PluginHandler, WebAuthnHandler + + +class WebauthnHandlerFactory: + handlers: List[WebAuthnHandler] + + def __init__(self): + self.handlers = [PluginHandler()] + + def get_handler(self) -> Optional[WebAuthnHandler]: + for handler in self.handlers: + if handler.is_available(): + return handler + return None diff --git a/packages/google-auth/google/oauth2/webauthn_types.py b/packages/google-auth/google/oauth2/webauthn_types.py new file mode 100644 index 000000000000..24e984f3d336 --- /dev/null +++ b/packages/google-auth/google/oauth2/webauthn_types.py @@ -0,0 +1,156 @@ +from dataclasses import dataclass +import json +from typing import Any, Dict, List, Optional + +from google.auth import exceptions + + +@dataclass(frozen=True) +class PublicKeyCredentialDescriptor: + """Descriptor for a security key based credential. + + https://www.w3.org/TR/webauthn-3/#dictionary-credential-descriptor + + Args: + id: credential id (key handle). + transports: <'usb'|'nfc'|'ble'|'internal'> List of supported transports. + """ + + id: str + transports: Optional[List[str]] = None + + def to_dict(self): + cred = {"type": "public-key", "id": self.id} + if self.transports: + cred["transports"] = self.transports + return cred + + +@dataclass +class AuthenticationExtensionsClientInputs: + """Client extensions inputs for WebAuthn extensions. + + Args: + appid: app id that can be asserted with in addition to rpid. + https://www.w3.org/TR/webauthn-3/#sctn-appid-extension + """ + + appid: Optional[str] = None + + def to_dict(self): + extensions = {} + if self.appid: + extensions["appid"] = self.appid + return extensions + + +@dataclass +class GetRequest: + """WebAuthn get request + + Args: + origin: Origin where the WebAuthn get assertion takes place. + rpid: Relying Party ID. + challenge: raw challenge. + timeout_ms: Timeout number in millisecond. + allow_credentials: List of allowed credentials. + user_verification: <'required'|'preferred'|'discouraged'> User verification requirement. + extensions: WebAuthn authentication extensions inputs. + """ + + origin: str + rpid: str + challenge: str + timeout_ms: Optional[int] = None + allow_credentials: Optional[List[PublicKeyCredentialDescriptor]] = None + user_verification: Optional[str] = None + extensions: Optional[AuthenticationExtensionsClientInputs] = None + + def to_json(self) -> str: + req_options: Dict[str, Any] = {"rpId": self.rpid, "challenge": self.challenge} + if self.timeout_ms: + req_options["timeout"] = self.timeout_ms + if self.allow_credentials: + req_options["allowCredentials"] = [ + c.to_dict() for c in self.allow_credentials + ] + if self.user_verification: + req_options["userVerification"] = self.user_verification + if self.extensions: + req_options["extensions"] = self.extensions.to_dict() + return json.dumps( + {"type": "get", "origin": self.origin, "requestData": req_options} + ) + + +@dataclass(frozen=True) +class AuthenticatorAssertionResponse: + """Authenticator response to a WebAuthn get (assertion) request. + + https://www.w3.org/TR/webauthn-3/#authenticatorassertionresponse + + Args: + client_data_json: client data JSON. + authenticator_data: authenticator data. + signature: signature. + user_handle: user handle. + """ + + client_data_json: str + authenticator_data: str + signature: str + user_handle: Optional[str] + + +@dataclass(frozen=True) +class GetResponse: + """WebAuthn get (assertion) response. + + Args: + id: credential id (key handle). + response: The authenticator assertion response. + authenticator_attachment: <'cross-platform'|'platform'> The attachment status of the authenticator. + client_extension_results: WebAuthn authentication extensions output results in a dictionary. + """ + + id: str + response: AuthenticatorAssertionResponse + authenticator_attachment: Optional[str] + client_extension_results: Optional[Dict] + + @staticmethod + def from_json(json_str: str): + """Verify and construct GetResponse from a JSON string.""" + try: + resp_json = json.loads(json_str) + except ValueError: + raise exceptions.MalformedError("Invalid Get JSON response") + if resp_json.get("type") != "getResponse": + raise exceptions.MalformedError( + "Invalid Get response type: {}".format(resp_json.get("type")) + ) + pk_cred = resp_json.get("responseData") + if pk_cred is None: + if resp_json.get("error"): + raise exceptions.ReauthFailError( + "WebAuthn.get failure: {}".format(resp_json["error"]) + ) + else: + raise exceptions.MalformedError("Get response is empty") + if pk_cred.get("type") != "public-key": + raise exceptions.MalformedError( + "Invalid credential type: {}".format(pk_cred.get("type")) + ) + assertion_json = pk_cred["response"] + assertion_resp = AuthenticatorAssertionResponse( + client_data_json=assertion_json["clientDataJSON"], + authenticator_data=assertion_json["authenticatorData"], + signature=assertion_json["signature"], + user_handle=assertion_json.get("userHandle"), + ) + return GetResponse( + id=pk_cred["id"], + response=assertion_resp, + authenticator_attachment=pk_cred.get("authenticatorAttachment"), + client_extension_results=pk_cred.get("clientExtensionResults"), + ) diff --git a/packages/google-auth/mypy.ini b/packages/google-auth/mypy.ini new file mode 100644 index 000000000000..574c5aed394b --- /dev/null +++ b/packages/google-auth/mypy.ini @@ -0,0 +1,3 @@ +[mypy] +python_version = 3.7 +namespace_packages = True diff --git a/packages/google-auth/noxfile.py b/packages/google-auth/noxfile.py new file mode 100644 index 000000000000..b0e4a100c92a --- /dev/null +++ b/packages/google-auth/noxfile.py @@ -0,0 +1,289 @@ +# Copyright 2019 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 os +import pathlib +import re +import shutil + +import nox + +CURRENT_DIRECTORY = pathlib.Path(__file__).parent.absolute() + +CLICK_VERSION = "click" +BLACK_VERSION = "black==23.7.0" +BLACK_PATHS = [ + "google", + "tests", + "tests_async", + "noxfile.py", + "setup.py", + "docs/conf.py", +] + +DEFAULT_PYTHON_VERSION = "3.14" +UNIT_TEST_PYTHON_VERSIONS = ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + +# Error if a python version is missing +nox.options.error_on_missing_interpreters = True + +# pypy will be run as a github action instead of through Kokoro +nox.options.sessions = [ + "lint", + "blacken", + "mypy", + # TODO(https://github.com/googleapis/google-auth-library-python/issues/1787): + # Remove or restore testing for Python 3.7/3.8 + "unit-3.9", + "unit-3.10", + "unit-3.11", + "unit-3.12", + "unit-3.13", + "unit-3.14", + # cover must be last to avoid error `No data to report` + "cover", + "docs", +] + + +@nox.session(python=DEFAULT_PYTHON_VERSION) +def lint(session): + session.install( + "flake8", "flake8-import-order", "docutils", CLICK_VERSION, BLACK_VERSION + ) + session.install("-e", ".") + session.run("black", "--check", *BLACK_PATHS) + session.run( + "flake8", + "--import-order-style=google", + "--application-import-names=google,tests,system_tests", + "google", + "tests", + "tests_async", + ) + session.run( + "python", "setup.py", "check", "--metadata", "--restructuredtext", "--strict" + ) + + +@nox.session(python=DEFAULT_PYTHON_VERSION) +def blacken(session): + """Run black. + Format code to uniform standard. + The Python version should be consistent with what is + supplied in the Python Owlbot postprocessor. + + https://github.com/googleapis/synthtool/blob/master/docker/owlbot/python/Dockerfile + """ + session.install(CLICK_VERSION, BLACK_VERSION) + session.run("black", *BLACK_PATHS) + + +@nox.session(python=DEFAULT_PYTHON_VERSION) +def mypy(session): + """Verify type hints are mypy compatible.""" + session.install("-e", ".") + session.install( + "mypy", + "types-cachetools", + "types-certifi", + "types-freezegun", + "types-pyOpenSSL", + "types-requests", + "types-setuptools", + "types-mock", + "pytest<8.0.0", + ) + session.run("mypy", "-p", "google", "-p", "tests", "-p", "tests_async") + + +@nox.session(python=UNIT_TEST_PYTHON_VERSIONS) +def unit(session): + constraints_path = str( + CURRENT_DIRECTORY / "testing" / f"constraints-{session.python}.txt" + ) + session.install("-e", ".[testing]", "-c", constraints_path) + session.run( + "pytest", + f"--junitxml=unit_{session.python}_sponge_log.xml", + "--cov=google.auth", + "--cov=google.oauth2", + "--cov=tests", + "--cov-report=term-missing", + "tests", + "tests_async", + ) + + +@nox.session(python=DEFAULT_PYTHON_VERSION) +def cover(session): + """Run the final coverage report. + + This outputs the coverage report aggregating coverage from the unit + test runs (not system test runs), and then erases coverage data. + """ + session.install("coverage", "pytest-cov") + session.run("coverage", "report", "--show-missing", "--fail-under=100") + session.run("coverage", "erase") + + +@nox.session(python="3.10") +def docs(session): + """Build the docs for this library.""" + + session.install("-e", ".[aiohttp]") + session.install("sphinx", "alabaster", "recommonmark", "sphinx-docstring-typing") + + shutil.rmtree(os.path.join("docs", "_build"), ignore_errors=True) + session.run( + "sphinx-build", + "-T", # show full traceback on exception + "-W", # warnings as errors + "-N", # no colors + "-b", + "html", + "-d", + os.path.join("docs", "_build", "doctrees", ""), + os.path.join("docs", ""), + os.path.join("docs", "_build", "html", ""), + ) + + +@nox.session(python="3.10") +def docfx(session): + """Build the docfx yaml files for this library.""" + + session.install("-e", ".") + session.install( + # We need to pin to specific versions of the `sphinxcontrib-*` packages + # which still support sphinx 4.x. + # See https://github.com/googleapis/sphinx-docfx-yaml/issues/344 + # and https://github.com/googleapis/sphinx-docfx-yaml/issues/345. + "sphinxcontrib-applehelp==1.0.4", + "sphinxcontrib-devhelp==1.0.2", + "sphinxcontrib-htmlhelp==2.0.1", + "sphinxcontrib-qthelp==1.0.3", + "sphinxcontrib-serializinghtml==1.1.5", + "gcp-sphinx-docfx-yaml", + "alabaster", + "recommonmark", + ) + + shutil.rmtree(os.path.join("docs", "_build"), ignore_errors=True) + session.run( + "sphinx-build", + "-T", # show full traceback on exception + "-N", # no colors + "-D", + ( + "extensions=sphinx.ext.autodoc," + "sphinx.ext.autosummary," + "docfx_yaml.extension," + "sphinx.ext.intersphinx," + "sphinx.ext.coverage," + "sphinx.ext.napoleon," + "sphinx.ext.todo," + "sphinx.ext.viewcode," + "recommonmark" + ), + "-b", + "html", + "-d", + os.path.join("docs", "_build", "doctrees", ""), + os.path.join("docs", ""), + os.path.join("docs", "_build", "html", ""), + ) + + +@nox.session(python="pypy") +def pypy(session): + session.install("-e", ".[testing]") + session.run( + "pytest", + f"--junitxml=unit_{session.python}_sponge_log.xml", + "--cov=google.auth", + "--cov=google.oauth2", + "--cov=tests", + "tests", + "tests_async", + ) + + +@nox.session(python=DEFAULT_PYTHON_VERSION) +@nox.parametrize( + "protobuf_implementation", + ["python", "upb", "cpp"], +) +def prerelease_deps(session, protobuf_implementation): + """ + Run all tests with pre-release versions of dependencies installed + rather than the standard non pre-release versions. + Pre-release versions can be installed using + `pip install --pre `. + """ + + if protobuf_implementation == "cpp" and session.python in ( + "3.11", + "3.12", + "3.13", + "3.14", + ): + session.skip("cpp implementation is not supported in python 3.11+") + + # Install all dependencies + session.install("-e", ".[testing]") + + # Because we test minimum dependency versions on the minimum Python + # version, the first version we test with in the unit tests sessions has a + # constraints file containing all dependencies and extras. + with open( + CURRENT_DIRECTORY + / "testing" + / f"constraints-{UNIT_TEST_PYTHON_VERSIONS[0]}.txt", + encoding="utf-8", + ) as constraints_file: + constraints_text = constraints_file.read() + + # Ignore leading whitespace and comment lines. + constraints_deps = [ + match.group(1) + for match in re.finditer( + r"^\s*(\S+)(?===\S+)", constraints_text, flags=re.MULTILINE + ) + ] + + # Install dependencies specified in `testing/constraints-X.txt`. + session.install(*constraints_deps) + + # Note: If a dependency is added to the `prerel_deps` list, + # the `core_dependencies_from_source` list in the `core_deps_from_source` + # nox session should also be updated. + prerel_deps = [ + "cachetools", + "pyasn1-modules", + "cryptography", + "requests", + "aiohttp", + ] + + for dep in prerel_deps: + session.install("--pre", "--no-deps", "--ignore-installed", dep) + + session.run( + "py.test", + "tests/unit", + env={ + "PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION": protobuf_implementation, + }, + ) diff --git a/packages/google-auth/owlbot.py b/packages/google-auth/owlbot.py new file mode 100644 index 000000000000..c2cd0fc20a90 --- /dev/null +++ b/packages/google-auth/owlbot.py @@ -0,0 +1,29 @@ +import synthtool as s +from synthtool import gcp + +common = gcp.CommonTemplates() + +# ---------------------------------------------------------------------------- +# Add templated files +# ---------------------------------------------------------------------------- +templated_files = common.py_library(unit_cov_level=100, cov_level=100) + + +s.move( + templated_files / ".kokoro", + excludes=[ + "continuous/common.cfg", + "presubmit/common.cfg", + "build.sh", + "samples/*", + ], +) # just move kokoro configs +s.move( + # needed by samples kokoro jobs + templated_files / ".trampolinerc" +) +s.move( + templated_files / "renovate.json", +) + +s.shell.run(["nox", "-s", "blacken"], hide_output=False) diff --git a/packages/google-auth/renovate.json b/packages/google-auth/renovate.json new file mode 100644 index 000000000000..c7875c469bd5 --- /dev/null +++ b/packages/google-auth/renovate.json @@ -0,0 +1,12 @@ +{ + "extends": [ + "config:base", + "group:all", + ":preserveSemverRanges", + ":disableDependencyDashboard" + ], + "ignorePaths": [".pre-commit-config.yaml", ".kokoro/requirements.txt", "setup.py", ".github/workflows/unittest.yml"], + "pip_requirements": { + "fileMatch": ["requirements-test.txt", "samples/[\\S/]*constraints.txt", "samples/[\\S/]*constraints-test.txt"] + } +} diff --git a/packages/google-auth/samples/cloud-client/snippets/authenticate_explicit_with_adc.py b/packages/google-auth/samples/cloud-client/snippets/authenticate_explicit_with_adc.py new file mode 100644 index 000000000000..8483bd7ea3be --- /dev/null +++ b/packages/google-auth/samples/cloud-client/snippets/authenticate_explicit_with_adc.py @@ -0,0 +1,55 @@ +# Copyright 2022 Google Inc. +# +# 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. + +# [START auth_cloud_explicit_adc] + +from google.cloud import storage + +import google.oauth2.credentials +import google.auth + + +def authenticate_explicit_with_adc(): + """ + List storage buckets by authenticating with ADC. + + // TODO(Developer): + // 1. Before running this sample, + // set up ADC as described in https://cloud.google.com/docs/authentication/external/set-up-adc + // 2. Replace the project variable. + // 3. Make sure you have the necessary permission to list storage buckets: "storage.buckets.list" + """ + + # Construct the Google credentials object which obtains the default configuration from your + # working environment. + # google.auth.default() will give you ComputeEngineCredentials + # if you are on a GCE (or other metadata server supported environments). + credentials, project_id = google.auth.default() + # If you are authenticating to a Cloud API, you can let the library include the default scope, + # https://www.googleapis.com/auth/cloud-platform, because IAM is used to provide fine-grained + # permissions for Cloud. + # If you need to provide a scope, specify it as follows: + # credentials = google.auth.default(scopes=scope) + # For more information on scopes to use, + # see: https://developers.google.com/identity/protocols/oauth2/scopes + + # Construct the Storage client. + storage_client = storage.Client(credentials=credentials, project=project_id) + buckets = storage_client.list_buckets() + print("Buckets:") + for bucket in buckets: + print(bucket.name) + print("Listed all storage buckets.") + +# [END auth_cloud_explicit_adc] diff --git a/packages/google-auth/samples/cloud-client/snippets/authenticate_implicit_with_adc.py b/packages/google-auth/samples/cloud-client/snippets/authenticate_implicit_with_adc.py new file mode 100644 index 000000000000..2c1a0414d39a --- /dev/null +++ b/packages/google-auth/samples/cloud-client/snippets/authenticate_implicit_with_adc.py @@ -0,0 +1,46 @@ +# Copyright 2022 Google Inc. +# +# 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. + +# [START auth_cloud_implicit_adc] + +from google.cloud import storage + + +def authenticate_implicit_with_adc(project_id="your-google-cloud-project-id"): + """ + When interacting with Google Cloud Client libraries, the library can auto-detect the + credentials to use. + + // TODO(Developer): + // 1. Before running this sample, + // set up ADC as described in https://cloud.google.com/docs/authentication/external/set-up-adc + // 2. Replace the project variable. + // 3. Make sure that the user account or service account that you are using + // has the required permissions. For this sample, you must have "storage.buckets.list". + Args: + project_id: The project id of your Google Cloud project. + """ + + # This snippet demonstrates how to list buckets. + # *NOTE*: Replace the client created below with the client required for your application. + # Note that the credentials are not specified when constructing the client. + # Hence, the client library will look for credentials using ADC. + storage_client = storage.Client(project=project_id) + buckets = storage_client.list_buckets() + print("Buckets:") + for bucket in buckets: + print(bucket.name) + print("Listed all storage buckets.") + +# [END auth_cloud_implicit_adc] diff --git a/packages/google-auth/samples/cloud-client/snippets/custom_aws_supplier.py b/packages/google-auth/samples/cloud-client/snippets/custom_aws_supplier.py new file mode 100644 index 000000000000..ec5bf8a10fb0 --- /dev/null +++ b/packages/google-auth/samples/cloud-client/snippets/custom_aws_supplier.py @@ -0,0 +1,117 @@ +# Copyright 2025 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 json +import os +import sys + +import boto3 +from dotenv import load_dotenv +from google.auth.aws import Credentials as AwsCredentials +from google.auth.aws import AwsSecurityCredentials, AwsSecurityCredentialsSupplier +from google.auth.exceptions import GoogleAuthError +from google.auth.transport.requests import AuthorizedSession + +load_dotenv() + + +class CustomAwsSupplier(AwsSecurityCredentialsSupplier): + """Custom AWS Security Credentials Supplier.""" + + def __init__(self): + """Initializes the Boto3 session, prioritizing environment variables for region.""" + # Explicitly read the region from the environment first. This ensures that + # a value from a .env file is picked up reliably for local testing. + region = os.getenv("AWS_REGION") or os.getenv("AWS_DEFAULT_REGION") + + # If region is None, Boto3's discovery chain will be used when needed. + self.session = boto3.Session(region_name=region) + self._cached_region = None + print(f"[INFO] CustomAwsSupplier initialized. Region from env: {region}") + + def get_aws_region(self, context, request) -> str: + """Returns the AWS region using Boto3's default provider chain.""" + if self._cached_region: + return self._cached_region + + # Accessing region_name will use the value from the constructor if provided, + # otherwise it triggers Boto3's lazy-loading discovery (e.g., metadata service). + self._cached_region = self.session.region_name + + if not self._cached_region: + print("[ERROR] Boto3 was unable to resolve an AWS region.", file=sys.stderr) + raise GoogleAuthError("Boto3 was unable to resolve an AWS region.") + + print(f"[INFO] Boto3 resolved AWS Region: {self._cached_region}") + return self._cached_region + + def get_aws_security_credentials(self, context, request=None) -> AwsSecurityCredentials: + """Retrieves AWS security credentials using Boto3's default provider chain.""" + aws_credentials = self.session.get_credentials() + if not aws_credentials: + print("[ERROR] Unable to resolve AWS credentials.", file=sys.stderr) + raise GoogleAuthError("Unable to resolve AWS credentials from the provider chain.") + + print(f"[INFO] Resolved AWS Access Key ID: {aws_credentials.access_key}") + + return AwsSecurityCredentials( + access_key_id=aws_credentials.access_key, + secret_access_key=aws_credentials.secret_key, + session_token=aws_credentials.token, + ) + + +def main(): + """Main function to demonstrate the custom AWS supplier.""" + print("--- Starting Script ---") + + gcp_audience = os.getenv("GCP_WORKLOAD_AUDIENCE") + sa_impersonation_url = os.getenv("GCP_SERVICE_ACCOUNT_IMPERSONATION_URL") + gcs_bucket_name = os.getenv("GCS_BUCKET_NAME") + + print(f"GCP_WORKLOAD_AUDIENCE: {gcp_audience}") + print(f"GCS_BUCKET_NAME: {gcs_bucket_name}") + + if not all([gcp_audience, sa_impersonation_url, gcs_bucket_name]): + print("[ERROR] Missing required environment variables.", file=sys.stderr) + raise GoogleAuthError("Missing required environment variables.") + + custom_supplier = CustomAwsSupplier() + + credentials = AwsCredentials( + audience=gcp_audience, + subject_token_type="urn:ietf:params:aws:token-type:aws4_request", + service_account_impersonation_url=sa_impersonation_url, + aws_security_credentials_supplier=custom_supplier, + scopes=['https://www.googleapis.com/auth/devstorage.read_write'], + ) + + bucket_url = f"https://storage.googleapis.com/storage/v1/b/{gcs_bucket_name}" + print(f"Request URL: {bucket_url}") + + authed_session = AuthorizedSession(credentials) + try: + print("Attempting to make authenticated request to Google Cloud Storage...") + res = authed_session.get(bucket_url) + res.raise_for_status() + print("\n--- SUCCESS! ---") + print("Successfully authenticated and retrieved bucket data:") + print(json.dumps(res.json(), indent=2)) + except Exception as e: + print("--- FAILED --- ", file=sys.stderr) + print(e, file=sys.stderr) + exit(1) + + +if __name__ == "__main__": + main() diff --git a/packages/google-auth/samples/cloud-client/snippets/custom_okta_supplier.py b/packages/google-auth/samples/cloud-client/snippets/custom_okta_supplier.py new file mode 100644 index 000000000000..12f83dcfaca0 --- /dev/null +++ b/packages/google-auth/samples/cloud-client/snippets/custom_okta_supplier.py @@ -0,0 +1,190 @@ +# Copyright 2025 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 json +import urllib.parse +import os +import time + +import requests +from dotenv import load_dotenv +from google.auth.exceptions import GoogleAuthError +from google.auth.identity_pool import Credentials as IdentityPoolClient +from google.auth.transport.requests import AuthorizedSession + +load_dotenv() + +# Workload Identity Pool Configuration +GCP_WORKLOAD_AUDIENCE = os.getenv("GCP_WORKLOAD_AUDIENCE") +SERVICE_ACCOUNT_IMPERSONATION_URL = os.getenv("GCP_SERVICE_ACCOUNT_IMPERSONATION_URL") +GCS_BUCKET_NAME = os.getenv("GCS_BUCKET_NAME") + +# Okta Configuration +OKTA_DOMAIN = os.getenv("OKTA_DOMAIN") +OKTA_CLIENT_ID = os.getenv("OKTA_CLIENT_ID") +OKTA_CLIENT_SECRET = os.getenv("OKTA_CLIENT_SECRET") + +# Constants +TOKEN_URL = "https://sts.googleapis.com/v1/token" +SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt" + + +class OktaClientCredentialsSupplier: + """A custom SubjectTokenSupplier that authenticates with Okta. + + This supplier uses the Client Credentials grant flow for machine-to-machine + (M2M) authentication with Okta. + """ + + def __init__(self, domain, client_id, client_secret): + self.okta_token_url = f"{domain}/oauth2/default/v1/token" + self.client_id = client_id + self.client_secret = client_secret + self.access_token = None + self.expiry_time = 0 + print("OktaClientCredentialsSupplier initialized.") + + def get_subject_token(self, context, request=None) -> str: + """Fetches a new token if the current one is expired or missing. + + Args: + context: The context object, not used in this implementation. + + Returns: + The Okta Access token. + """ + # Check if the current token is still valid (with a 60-second buffer). + is_token_valid = self.access_token and time.time() < self.expiry_time - 60 + + if is_token_valid: + print("[Supplier] Returning cached Okta Access token.") + return self.access_token + + print( + "[Supplier] Token is missing or expired. Fetching new Okta Access token..." + ) + self._fetch_okta_access_token() + return self.access_token + + def _fetch_okta_access_token(self): + """Performs the Client Credentials grant flow with Okta.""" + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + } + data = { + "grant_type": "client_credentials", + "scope": "gcp.test.read", + } + encoded_data = urllib.parse.urlencode(data) + + try: + response = requests.post( + self.okta_token_url, + headers=headers, + data=encoded_data, + auth=(self.client_id, self.client_secret), + ) + response.raise_for_status() + token_data = response.json() + + if "access_token" in token_data and "expires_in" in token_data: + self.access_token = token_data["access_token"] + self.expiry_time = time.time() + token_data["expires_in"] + print( + f"[Supplier] Successfully received Access Token from Okta. " + f"Expires in {token_data['expires_in']} seconds." + ) + else: + raise GoogleAuthError( + "Access token or expires_in not found in Okta response." + ) + except requests.exceptions.RequestException as e: + print(f"[Supplier] Error fetching token from Okta: {e}") + if e.response: + print(f"[Supplier] Okta response: {e.response.text}") + raise GoogleAuthError( + "Failed to authenticate with Okta using Client Credentials grant." + ) from e + + +def main(): + """Main function to demonstrate the custom Okta supplier. + + TODO(Developer): + 1. Before running this sample, set up your environment variables. You can do + this by creating a .env file in the same directory as this script and + populating it with the following variables: + - GCP_WORKLOAD_AUDIENCE: The audience for the GCP workload identity pool. + - GCP_SERVICE_ACCOUNT_IMPERSONATION_URL: The URL for service account impersonation (optional). + - GCS_BUCKET_NAME: The name of the GCS bucket to access. + - OKTA_DOMAIN: Your Okta domain (e.g., https://dev-12345.okta.com). + - OKTA_CLIENT_ID: The Client ID of your Okta M2M application. + - OKTA_CLIENT_SECRET: The Client Secret of your Okta M2M application. + """ + if not all( + [ + GCP_WORKLOAD_AUDIENCE, + GCS_BUCKET_NAME, + OKTA_DOMAIN, + OKTA_CLIENT_ID, + OKTA_CLIENT_SECRET, + ] + ): + raise GoogleAuthError( + "Missing required environment variables. Please check your .env file." + ) + + # 1. Instantiate the custom supplier with Okta credentials. + okta_supplier = OktaClientCredentialsSupplier( + OKTA_DOMAIN, OKTA_CLIENT_ID, OKTA_CLIENT_SECRET + ) + + # 2. Instantiate an IdentityPoolClient. + client = IdentityPoolClient( + audience=GCP_WORKLOAD_AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + subject_token_supplier=okta_supplier, + # If you choose to provide explicit scopes: use the `scopes` parameter. + default_scopes=['https://www.googleapis.com/auth/cloud-platform'], + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + ) + + # 3. Construct the URL for the Cloud Storage JSON API. + bucket_url = f"https://storage.googleapis.com/storage/v1/b/{GCS_BUCKET_NAME}" + print(f"[Test] Getting metadata for bucket: {GCS_BUCKET_NAME}...") + print(f"[Test] Request URL: {bucket_url}") + + # 4. Use the client to make an authenticated request. + authed_session = AuthorizedSession(client) + try: + res = authed_session.get(bucket_url) + res.raise_for_status() + print("\n--- SUCCESS! ---") + print("Successfully authenticated and retrieved bucket data:") + print(json.dumps(res.json(), indent=2)) + except requests.exceptions.RequestException as e: + print("\n--- FAILED ---") + print(f"Request failed: {e}") + if e.response: + print(f"Response: {e.response.text}") + exit(1) + except GoogleAuthError as e: + print("\n--- FAILED ---") + print(f"Authentication or request failed: {e}") + exit(1) + + +if __name__ == "__main__": + main() diff --git a/packages/google-auth/samples/cloud-client/snippets/idtoken_from_impersonated_credentials.py b/packages/google-auth/samples/cloud-client/snippets/idtoken_from_impersonated_credentials.py new file mode 100644 index 000000000000..a27e6cffd797 --- /dev/null +++ b/packages/google-auth/samples/cloud-client/snippets/idtoken_from_impersonated_credentials.py @@ -0,0 +1,75 @@ +# Copyright 2022 Google Inc. +# +# 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. + +# [auth_cloud_idtoken_impersonated_credentials] + +import google +from google.auth import impersonated_credentials +import google.auth.transport.requests + + +def idtoken_from_impersonated_credentials( + impersonated_service_account: str, scope: str, target_audience: str): + """ + Use a service account (SA1) to impersonate as another service account (SA2) and obtain id token + for the impersonated account. + To obtain token for SA2, SA1 should have the "roles/iam.serviceAccountTokenCreator" permission + on SA2. + + Args: + impersonated_service_account: The name of the privilege-bearing service account for whom the credential is created. + Examples: name@project.service.gserviceaccount.com + + scope: Provide the scopes that you might need to request to access Google APIs, + depending on the level of access you need. + For this example, we use the cloud-wide scope and use IAM to narrow the permissions. + https://cloud.google.com/docs/authentication#authorization_for_services + For more information, see: https://developers.google.com/identity/protocols/oauth2/scopes + + target_audience: The service name for which the id token is requested. Service name refers to the + logical identifier of an API service, such as "iap.googleapis.com". + Examples: iap.googleapis.com + """ + + # Construct the GoogleCredentials object which obtains the default configuration from your + # working environment. + credentials, project_id = google.auth.default() + + # Create the impersonated credential. + target_credentials = impersonated_credentials.Credentials( + source_credentials=credentials, + target_principal=impersonated_service_account, + # delegates: The chained list of delegates required to grant the final accessToken. + # For more information, see: + # https://cloud.google.com/iam/docs/create-short-lived-credentials-direct#sa-credentials-permissions + # Delegate is NOT USED here. + delegates=[], + target_scopes=[scope], + lifetime=300) + + # Set the impersonated credential, target audience and token options. + id_creds = impersonated_credentials.IDTokenCredentials( + target_credentials, + target_audience=target_audience, + include_email=True) + + # Get the ID token. + # Once you've obtained the ID token, use it to make an authenticated call + # to the target audience. + request = google.auth.transport.requests.Request() + id_creds.refresh(request) + # token = id_creds.token + print("Generated ID token.") + +# [auth_cloud_idtoken_impersonated_credentials] diff --git a/packages/google-auth/samples/cloud-client/snippets/idtoken_from_metadata_server.py b/packages/google-auth/samples/cloud-client/snippets/idtoken_from_metadata_server.py new file mode 100644 index 000000000000..b59a055447a1 --- /dev/null +++ b/packages/google-auth/samples/cloud-client/snippets/idtoken_from_metadata_server.py @@ -0,0 +1,50 @@ +# Copyright 2022 Google Inc. +# +# 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. + +# [START auth_cloud_idtoken_metadata_server] + +import google +import google.oauth2.credentials +from google.auth import compute_engine +import google.auth.transport.requests + + +def idtoken_from_metadata_server(url: str): + """ + Use the Google Cloud metadata server in the Cloud Run (or AppEngine or Kubernetes etc.,) + environment to create an identity token and add it to the HTTP request as part of an + Authorization header. + + Args: + url: The url or target audience to obtain the ID token for. + Examples: http://www.example.com + """ + + request = google.auth.transport.requests.Request() + # Set the target audience. + # Setting "use_metadata_identity_endpoint" to "True" will make the request use the default application + # credentials. Optionally, you can also specify a specific service account to use by mentioning + # the service_account_email. + credentials = compute_engine.IDTokenCredentials( + request=request, target_audience=url, use_metadata_identity_endpoint=True + ) + + # Get the ID token. + # Once you've obtained the ID token, use it to make an authenticated call + # to the target audience. + credentials.refresh(request) + # print(credentials.token) + print("Generated ID token.") + +# [END auth_cloud_idtoken_metadata_server] diff --git a/packages/google-auth/samples/cloud-client/snippets/idtoken_from_service_account.py b/packages/google-auth/samples/cloud-client/snippets/idtoken_from_service_account.py new file mode 100644 index 000000000000..912035b0bf9d --- /dev/null +++ b/packages/google-auth/samples/cloud-client/snippets/idtoken_from_service_account.py @@ -0,0 +1,50 @@ +# Copyright 2022 Google Inc. +# +# 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. + +# [START auth_cloud_idtoken_service_account] + +import google.auth +import google.auth.transport.requests + +from google.oauth2 import service_account + + +def get_idToken_from_serviceaccount(json_credential_path: str, target_audience: str): + """ + TODO(Developer): Replace the below variables before running the code. + + *NOTE*: + Using service account keys introduces risk; they are long-lived, and can be used by anyone + that obtains the key. Proper rotation and storage reduce this risk but do not eliminate it. + For these reasons, you should consider an alternative approach that + does not use a service account key. Several alternatives to service account keys + are described here: + https://cloud.google.com/docs/authentication/external/set-up-adc + + Args: + json_credential_path: Path to the service account json credential file. + target_audience: The url or target audience to obtain the ID token for. + Examples: http://www.abc.com + """ + + # Obtain the id token by providing the json file path and target audience. + credentials = service_account.IDTokenCredentials.from_service_account_file( + filename=json_credential_path, + target_audience=target_audience) + + request = google.auth.transport.requests.Request() + credentials.refresh(request) + print("Generated ID token.") + +# [END auth_cloud_idtoken_service_account] diff --git a/packages/google-auth/samples/cloud-client/snippets/noxfile.py b/packages/google-auth/samples/cloud-client/snippets/noxfile.py new file mode 100644 index 000000000000..c21466d4f024 --- /dev/null +++ b/packages/google-auth/samples/cloud-client/snippets/noxfile.py @@ -0,0 +1,85 @@ +# Copyright 2019 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 pathlib + +import nox + +CURRENT_DIRECTORY = pathlib.Path(__file__).parent.absolute() + +# https://github.com/psf/black/issues/2964, pin click version to 8.0.4 to +# avoid incompatiblity with black. +CLICK_VERSION = "click==8.0.4" +BLACK_VERSION = "black==19.3b0" +BLACK_PATHS = [ + "google", + "tests", + "tests_async", + "noxfile.py", + "setup.py", + "docs/conf.py", +] + + +# Error if a python version is missing +nox.options.error_on_missing_interpreters = True + +# +# Style Checks +# + + +# Linting with flake8. +# +# We ignore the following rules: +# E203: whitespace before ‘:’ +# E266: too many leading ‘#’ for block comment +# E501: line too long +# I202: Additional newline in a section of imports +# +# We also need to specify the rules which are ignored by default: +# ['E226', 'W504', 'E126', 'E123', 'W503', 'E24', 'E704', 'E121'] +FLAKE8_COMMON_ARGS = [ + "--show-source", + "--builtin=gettext", + "--max-complexity=20", + "--exclude=.nox,.cache,env,lib,generated_pb2,*_pb2.py,*_pb2_grpc.py", + "--ignore=E121,E123,E126,E203,E226,E24,E266,E501,E704,W503,W504,I202", + "--max-line-length=88", +] + + +@nox.session(python=["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]) +def unit(session): + # constraints_path = str( + # CURRENT_DIRECTORY / "testing" / f"constraints-{session.python}.txt" + # ) + session.install("-r", "requirements.txt") + # session.install("-e", ".") + session.run( + "pytest", + f"--junitxml=unit_{session.python}_sponge_log.xml", + "snippets_test.py", + # "tests_async", + ) + + +@nox.session +def lint(session: nox.sessions.Session) -> None: + session.install("flake8") + + args = FLAKE8_COMMON_ARGS + [ + ".", + ] + session.run("flake8", *args) diff --git a/packages/google-auth/samples/cloud-client/snippets/noxfile_config.py b/packages/google-auth/samples/cloud-client/snippets/noxfile_config.py new file mode 100644 index 000000000000..e892b338fcea --- /dev/null +++ b/packages/google-auth/samples/cloud-client/snippets/noxfile_config.py @@ -0,0 +1,38 @@ +# 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 inported from +# the noxfile.py. + +# The source of truth: +# https://github.com/GoogleCloudPlatform/python-docs-samples/blob/master/noxfile_config.py + +TEST_CONFIG_OVERRIDE = { + # You can opt out from the test for specific Python versions. + "ignored_versions": ["2.7"], + # Old samples are opted out of enforcing Python type hints + # All new samples should feature them + "enforce_type_hints": True, + # 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": "GOOGLE_CLOUD_PROJECT", + # A dictionary you want to inject into your test. Don't put any + # secrets here. These values will override predefined values. + "envs": {}, +} diff --git a/packages/google-auth/samples/cloud-client/snippets/requirements.txt b/packages/google-auth/samples/cloud-client/snippets/requirements.txt new file mode 100644 index 000000000000..97f256bd8003 --- /dev/null +++ b/packages/google-auth/samples/cloud-client/snippets/requirements.txt @@ -0,0 +1,7 @@ +google-cloud-compute==1.5.1 +google-cloud-storage==3.1.0 +google-auth==2.41.1 +pytest==7.1.2 +boto3>=1.26.0 +requests==2.32.3 +python-dotenv==1.1.1 \ No newline at end of file diff --git a/packages/google-auth/samples/cloud-client/snippets/snippets_test.py b/packages/google-auth/samples/cloud-client/snippets/snippets_test.py new file mode 100644 index 000000000000..18088284392e --- /dev/null +++ b/packages/google-auth/samples/cloud-client/snippets/snippets_test.py @@ -0,0 +1,77 @@ +# Copyright 2022 Google Inc. +# +# 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 re + +from _pytest.capture import CaptureFixture + +import authenticate_explicit_with_adc +import authenticate_implicit_with_adc +import idtoken_from_metadata_server +import idtoken_from_service_account +# from system_tests.noxfile import SERVICE_ACCOUNT_FILE +import verify_google_idtoken + +import google +from google.oauth2 import service_account +import google.auth.transport.requests +import os + +CREDENTIALS, PROJECT = google.auth.default() +SERVICE_ACCOUNT_FILE = os.getenv("GOOGLE_APPLICATION_CREDENTIALS") + + +def test_authenticate_explicit_with_adc(capsys: CaptureFixture): + authenticate_explicit_with_adc.authenticate_explicit_with_adc() + out, err = capsys.readouterr() + assert re.search("Listed all storage buckets.", out) + + +def test_authenticate_implicit_with_adc(capsys: CaptureFixture): + authenticate_implicit_with_adc.authenticate_implicit_with_adc(PROJECT) + out, err = capsys.readouterr() + assert re.search("Listed all storage buckets.", out) + + +def test_idtoken_from_metadata_server(capsys: CaptureFixture): + idtoken_from_metadata_server.idtoken_from_metadata_server("https://www.google.com") + out, err = capsys.readouterr() + assert re.search("Generated ID token.", out) + + +def test_idtoken_from_service_account(capsys: CaptureFixture): + idtoken_from_service_account.get_idToken_from_serviceaccount( + SERVICE_ACCOUNT_FILE, + "iap.googleapis.com") + out, err = capsys.readouterr() + assert re.search("Generated ID token.", out) + + +def test_verify_google_idtoken(): + idtoken = get_idtoken_from_service_account(SERVICE_ACCOUNT_FILE, "iap.googleapis.com") + + verify_google_idtoken.verify_google_idtoken( + idtoken, + "iap.googleapis.com", + "https://www.googleapis.com/oauth2/v3/certs" + ) + + +def get_idtoken_from_service_account(json_credential_path: str, target_audience: str): + credentials = service_account.IDTokenCredentials.from_service_account_file( + filename=json_credential_path, + target_audience=target_audience) + + request = google.auth.transport.requests.Request() + credentials.refresh(request) + return credentials.token diff --git a/packages/google-auth/samples/cloud-client/snippets/verify_google_idtoken.py b/packages/google-auth/samples/cloud-client/snippets/verify_google_idtoken.py new file mode 100644 index 000000000000..35b88c99ecd4 --- /dev/null +++ b/packages/google-auth/samples/cloud-client/snippets/verify_google_idtoken.py @@ -0,0 +1,62 @@ +# Copyright 2022 Google Inc. +# +# 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. + +# [START auth_cloud_verify_google_idtoken] + +import google +import google.auth.transport.requests +from google.oauth2 import id_token + + +def verify_google_idtoken(idtoken: str, audience="iap.googleapis.com", + jwk_url="https://www.googleapis.com/oauth2/v3/certs"): + """ + Verifies the obtained Google id token. This is done at the receiving end of the OIDC endpoint. + The most common use case for verifying the ID token is when you are protecting + your own APIs with IAP. Google services already verify credentials as a platform, + so verifying ID tokens before making Google API calls is usually unnecessary. + + Args: + idtoken: The Google ID token to verify. + + audience: The service name for which the id token is requested. Service name refers to the + logical identifier of an API service, such as "iap.googleapis.com". + + jwk_url: To verify id tokens, get the Json Web Key endpoint (jwk). + OpenID Connect allows the use of a "Discovery document," a JSON document found at a + well-known location containing key-value pairs which provide details about the + OpenID Connect provider's configuration. + For more information on validating the jwt, see: + https://developers.google.com/identity/protocols/oauth2/openid-connect#validatinganidtoken + + Here, we validate Google's token using Google's OpenID Connect service (jwkUrl). + For more information on jwk,see: + https://auth0.com/docs/secure/tokens/json-web-tokens/json-web-key-sets + """ + + request = google.auth.transport.requests.Request() + # Set the parameters and verify the token. + # Setting "certs_url" is optional. When verifying a Google ID token, this is set by default. + result = id_token.verify_token(idtoken, request, audience, clock_skew_in_seconds=10) + + # Verify that the token contains subject and email claims. + # Get the User id. + if not result["sub"] is None: + print(f"User id: {result['sub']}") + # Optionally, if "INCLUDE_EMAIL" was set in the token options, check if the + # email was verified. + if result['email_verified'] == "True": + print(f"Email verified {result['email']}") + +# [END auth_cloud_verify_google_idtoken] diff --git a/packages/google-auth/scripts/decrypt-secrets.sh b/packages/google-auth/scripts/decrypt-secrets.sh new file mode 100755 index 000000000000..7e7f03bdca53 --- /dev/null +++ b/packages/google-auth/scripts/decrypt-secrets.sh @@ -0,0 +1,34 @@ +#!/bin/bash + +# Copyright 2015 Google Inc. All rights reserved. +# +# 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. + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +ROOT=$( dirname "$DIR" ) + +# Work from the project root. +cd $ROOT + +# Create working directory if not exists. system_tests/data is not tracked by +# Git to prevent the secrets from being leaked online. +mkdir -p system_tests/data + +gcloud kms decrypt \ + --location=global \ + --keyring=ci \ + --key=kokoro-secrets \ + --ciphertext-file=system_tests/secrets.tar.enc \ + --plaintext-file=system_tests/secrets.tar +tar xvf system_tests/secrets.tar +rm system_tests/secrets.tar diff --git a/packages/google-auth/scripts/encrypt-secrets.sh b/packages/google-auth/scripts/encrypt-secrets.sh new file mode 100755 index 000000000000..fba27fba0f9d --- /dev/null +++ b/packages/google-auth/scripts/encrypt-secrets.sh @@ -0,0 +1,34 @@ +#!/bin/bash + +# Copyright 2015 Google Inc. All rights reserved. +# +# 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. + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +ROOT=$( dirname "$DIR" ) + +# Work from the project root. +cd $ROOT + +tar cvf system_tests/secrets.tar system_tests/data + +gcloud kms encrypt \ + --location=global \ + --keyring=ci \ + --key=kokoro-secrets \ + --plaintext-file=system_tests/secrets.tar \ + --ciphertext-file=system_tests/secrets.tar.enc + +rm system_tests/secrets.tar + +rm system_tests/data \ No newline at end of file diff --git a/packages/google-auth/scripts/setup_external_accounts.sh b/packages/google-auth/scripts/setup_external_accounts.sh new file mode 100644 index 000000000000..ecc879b9395f --- /dev/null +++ b/packages/google-auth/scripts/setup_external_accounts.sh @@ -0,0 +1,113 @@ +#!/bin/bash +# Copyright 2021 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. + +# This file is a mostly common setup file to ensure all workload identity +# federation integration tests are set up in a consistent fashion across the +# languages in our various client libraries. It assumes that the current user +# has the relevant permissions to run each of the commands listed. + +# This script needs to be run once. It will do the following: +# 1. Create a random workload identity pool. +# 2. Create a random OIDC provider in that pool which uses the +# accounts.google.com as the issuer and the default STS audience as the +# allowed audience. This audience will be validated on STS token exchange. +# 3. Enable OIDC tokens generated by the current service account to impersonate +# the service account. (Identified by the OIDC token sub field which is the +# service account client ID). +# 4. Create a random AWS provider in that pool which uses the provided AWS +# account ID. +# 5. Enable AWS provider to impersonate the service account. (Principal is +# identified by the AWS role name). +# 6. Print out the STS audience fields associated with the created providers +# after the setup completes successfully so that they can be used in the +# tests. These will be copied and used as the global _AUDIENCE_OIDC and +# _AUDIENCE_AWS constants in system_tests/system_tests_sync/test_external_accounts.py. +# +# It is safe to run the setup script again. A new pool is created and new +# audiences are printed. If run multiple times, it is advisable to delete +# unused pools. Note that deleted pools are soft deleted and may remain for +# a while before they are completely deleted. The old pool ID cannot be used +# in the meantime. +# +# For AWS tests, an AWS developer account is needed. +# The following AWS prerequisite setup is needed. +# 1. An OIDC Google identity provider needs to be created with the following: +# issuer: accounts.google.com +# audience: Use the client_id of the service account. +# 2. A role for OIDC web identity federation is needed with the created Google +# provider as a trusted entity: +# "accounts.google.com:aud": "$CLIENT_ID" +# The steps are documented at: +# https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-idp_oidc.html + +suffix="" + +function generate_random_string () { + local valid_chars=abcdefghijklmnopqrstuvwxyz0123456789 + for i in {1..8} ; do + suffix+="${valid_chars:RANDOM%${#valid_chars}:1}" + done +} + +generate_random_string + +pool_id="pool-"$suffix +oidc_provider_id="oidc-"$suffix +aws_provider_id="aws-"$suffix + +# TODO: Fill in. +project_id="stellar-day-254222" +project_number="79992041559" +aws_account_id="077071391996" +aws_role_name="ci-python-test" +service_account_email="kokoro@stellar-day-254222.iam.gserviceaccount.com" +sub="104692443208068386138" + +oidc_aud="//iam.googleapis.com/projects/$project_number/locations/global/workloadIdentityPools/$pool_id/providers/$oidc_provider_id" +aws_aud="//iam.googleapis.com/projects/$project_number/locations/global/workloadIdentityPools/$pool_id/providers/$aws_provider_id" + +gcloud config set project $project_id + +# Create the Workload Identity Pool. +gcloud beta iam workload-identity-pools create $pool_id \ + --location="global" \ + --description="Test pool" \ + --display-name="Test pool for Python" + +# Create the OIDC Provider. +gcloud beta iam workload-identity-pools providers create-oidc $oidc_provider_id \ + --workload-identity-pool=$pool_id \ + --issuer-uri="https://accounts.google.com" \ + --location="global" \ + --attribute-mapping="google.subject=assertion.sub" + +# Create the AWS Provider. +gcloud beta iam workload-identity-pools providers create-aws $aws_provider_id \ + --workload-identity-pool=$pool_id \ + --account-id=$aws_account_id \ + --location="global" + +# Give permission to impersonate the service account. +gcloud iam service-accounts add-iam-policy-binding $service_account_email \ +--role roles/iam.workloadIdentityUser \ +--member "principal://iam.googleapis.com/projects/$project_number/locations/global/workloadIdentityPools/$pool_id/subject/$sub" + +gcloud iam service-accounts add-iam-policy-binding $service_account_email \ + --role roles/iam.workloadIdentityUser \ + --member "principalSet://iam.googleapis.com/projects/$project_number/locations/global/workloadIdentityPools/$pool_id/attribute.aws_role/arn:aws:sts::$aws_account_id:assumed-role/$aws_role_name" + +echo "OIDC audience: "$oidc_aud +echo "AWS audience: "$aws_aud +echo "AWS role: arn:aws:iam::$aws_account_id:role/$aws_role_name" diff --git a/packages/google-auth/scripts/travis.sh b/packages/google-auth/scripts/travis.sh new file mode 100755 index 000000000000..2c34091c588c --- /dev/null +++ b/packages/google-auth/scripts/travis.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +# Copyright 2015 Google Inc. All rights reserved. +# +# 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. + +set -eo pipefail + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +ROOT=$( dirname "$DIR" ) + +# Work from the project root. +cd $ROOT + +# Decrypt secrets and run system tests if not on an external PR. +if [[ -n $SYSTEM_TEST ]]; then + if [[ $TRAVIS_SECURE_ENV_VARS == "true" ]]; then + echo 'Extracting secrets.' + scripts/decrypt-secrets.sh "$SECRETS_PASSWORD" + # Prevent build failures from leaking our password. + # looking at you, Tox. + export SECRETS_PASSWORD="" + else + # This is an external PR, so just mark system tests as green. + echo 'In system test but secrets are not available, skipping.' + exit 0 + fi +fi + +# Run nox. +echo "Running nox..." +nox \ No newline at end of file diff --git a/packages/google-auth/setup.cfg b/packages/google-auth/setup.cfg new file mode 100644 index 000000000000..7c2b2874c477 --- /dev/null +++ b/packages/google-auth/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal = 1 \ No newline at end of file diff --git a/packages/google-auth/setup.py b/packages/google-auth/setup.py new file mode 100644 index 000000000000..380847c50c20 --- /dev/null +++ b/packages/google-auth/setup.py @@ -0,0 +1,141 @@ +# Copyright 2014 Google Inc. +# +# 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 io +import os + +from setuptools import find_namespace_packages +from setuptools import setup + + +DEPENDENCIES = ( + "cachetools>=2.0.0,<7.0", + "pyasn1-modules>=0.2.1", + # rsa==4.5 is the last version to support 2.7 + # https://github.com/sybrenstuvel/python-rsa/issues/152#issuecomment-643470233 + "rsa>=3.1.4,<5", +) + +# TODO(https://github.com/googleapis/google-auth-library-python/issues/1737): Unit test fails with +# `No module named 'cryptography.hazmat.backends.openssl.x509' for Python 3.7``. +cryptography_base_require = [ + "cryptography >= 38.0.3", + "cryptography < 39.0.0; python_version < '3.8'", +] + +requests_extra_require = ["requests >= 2.20.0, < 3.0.0"] + +aiohttp_extra_require = ["aiohttp >= 3.6.2, < 4.0.0", *requests_extra_require] + +pyjwt_extra_require = ["pyjwt>=2.0", *cryptography_base_require] + +reauth_extra_require = ["pyu2f>=0.1.5"] + +# TODO(https://github.com/googleapis/google-auth-library-python/issues/1738): Add bounds for cryptography and pyopenssl dependencies. +enterprise_cert_extra_require = ["cryptography", "pyopenssl"] + +pyopenssl_extra_require = ["pyopenssl>=20.0.0", cryptography_base_require] + +# TODO(https://github.com/googleapis/google-auth-library-python/issues/1739): Add bounds for urllib3 and packaging dependencies. +urllib3_extra_require = ["urllib3", "packaging"] + +# Unit test requirements. +testing_extra_require = [ + # TODO(https://github.com/googleapis/google-auth-library-python/issues/1735): Remove `grpcio` from testing requirements once an extra is added for `grpcio` dependency. + "grpcio", + "flask", + "freezegun", + "mock", + # TODO(https://github.com/googleapis/google-auth-library-python/issues/1736): Remove `oauth2client` from testing requirements once an extra is added for `oauth2client` dependency. + "oauth2client", + *pyjwt_extra_require, + "pytest", + "pytest-cov", + "pytest-localserver", + *pyopenssl_extra_require, + *reauth_extra_require, + "responses", + *urllib3_extra_require, + # Async Dependencies + *aiohttp_extra_require, + "aioresponses", + "pytest-asyncio", + # TODO(https://github.com/googleapis/google-auth-library-python/issues/1665): Remove the pinned version of pyopenssl + # once `TestDecryptPrivateKey::test_success` is updated to remove the deprecated `OpenSSL.crypto.sign` and + # `OpenSSL.crypto.verify` methods. See: https://www.pyopenssl.org/en/latest/changelog.html#id3. + "pyopenssl < 24.3.0", + # TODO(https://github.com/googleapis/google-auth-library-python/issues/1722): `test_aiohttp_requests` depend on + # aiohttp < 3.10.0 which is a bug. Investigate and remove the pinned aiohttp version. + "aiohttp < 3.10.0", +] + +extras = { + "aiohttp": aiohttp_extra_require, + "enterprise_cert": enterprise_cert_extra_require, + "pyopenssl": pyopenssl_extra_require, + "pyjwt": pyjwt_extra_require, + "reauth": reauth_extra_require, + "requests": requests_extra_require, + "testing": testing_extra_require, + "urllib3": urllib3_extra_require, + # TODO(https://github.com/googleapis/google-auth-library-python/issues/1735): Add an extra for `grpcio` dependency. + # TODO(https://github.com/googleapis/google-auth-library-python/issues/1736): Add an extra for `oauth2client` dependency. +} + +with io.open("README.rst", "r") as fh: + long_description = fh.read() + +package_root = os.path.abspath(os.path.dirname(__file__)) + +version = {} +with open(os.path.join(package_root, "google/auth/version.py")) as fp: + exec(fp.read(), version) +version = version["__version__"] + +setup( + name="google-auth", + version=version, + author="Google Cloud Platform", + author_email="googleapis-packages@google.com", + description="Google Authentication Library", + long_description=long_description, + url="https://github.com/googleapis/google-cloud-python/tree/main/packages/google-auth", + packages=find_namespace_packages( + exclude=("tests*", "system_tests*", "docs*", "samples*") + ), + package_data={"google.auth": ["py.typed"], "google.oauth2": ["py.typed"]}, + install_requires=DEPENDENCIES, + extras_require=extras, + python_requires=">=3.7", + license="Apache 2.0", + keywords="google auth oauth client", + classifiers=[ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Operating System :: POSIX", + "Operating System :: Microsoft :: Windows", + "Operating System :: MacOS :: MacOS X", + "Operating System :: OS Independent", + "Topic :: Internet :: WWW/HTTP", + ], +) diff --git a/packages/google-auth/system_tests/__init__.py b/packages/google-auth/system_tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/google-auth/system_tests/noxfile.py b/packages/google-auth/system_tests/noxfile.py new file mode 100644 index 000000000000..8630f9cedb2a --- /dev/null +++ b/packages/google-auth/system_tests/noxfile.py @@ -0,0 +1,449 @@ +# 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. + +"""Noxfile for automating system tests. + +This file handles setting up environments needed by the system tests. This +separates the tests from their environment configuration. + +See the `nox docs`_ for details on how this file works: + +.. _nox docs: http://nox.readthedocs.io/en/latest/ +""" + +import os +import pathlib +import shutil +import tempfile + +import nox + +HERE = os.path.abspath(os.path.dirname(__file__)) +LIBRARY_DIR = os.path.abspath(os.path.dirname(HERE)) +DATA_DIR = os.path.join(HERE, "data") +SERVICE_ACCOUNT_FILE = os.path.join(DATA_DIR, "service_account.json") +AUTHORIZED_USER_FILE = os.path.join(DATA_DIR, "authorized_user.json") +EXPLICIT_CREDENTIALS_ENV = "GOOGLE_APPLICATION_CREDENTIALS" +EXPLICIT_PROJECT_ENV = "GOOGLE_CLOUD_PROJECT" +EXPECT_PROJECT_ENV = "EXPECT_PROJECT_ID" +ALLOW_PLUGGABLE_ENV = "GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES" + + +# The download location for the Cloud SDK +CLOUD_SDK_DIST_FILENAME = "google-cloud-sdk.tar.gz" +CLOUD_SDK_DOWNLOAD_URL = "https://dl.google.com/dl/cloudsdk/release/{}".format( + CLOUD_SDK_DIST_FILENAME +) + +# This environment variable is recognized by the Cloud SDK and overrides +# the location of the SDK's configuration files (which is usually at +# ${HOME}/.config). +CLOUD_SDK_CONFIG_ENV = "CLOUDSDK_CONFIG" + +# If set, this is where the environment setup will install the Cloud SDK. +# If unset, it will download the SDK to a temporary directory. +CLOUD_SDK_ROOT = os.environ.get("CLOUD_SDK_ROOT") + +if CLOUD_SDK_ROOT is not None: + CLOUD_SDK_ROOT = pathlib.Path(CLOUD_SDK_ROOT) + if not CLOUD_SDK_ROOT.exists() or not CLOUD_SDK_ROOT.is_dir(): + print("{} did not exist! Please set the CLOUD_SDK_ROOT environment variable to a directory that exists".format(CLOUD_SDK_ROOT)) + exit(1) +else: + CLOUD_SDK_ROOT = pathlib.Path(tempfile.mkdtemp()) + +# The full path the cloud sdk install directory +CLOUD_SDK_INSTALL_DIR = CLOUD_SDK_ROOT.joinpath("google-cloud-sdk") + +# The full path to the gcloud cli executable. +GCLOUD = str(CLOUD_SDK_INSTALL_DIR.joinpath("bin", "gcloud")) + +# Cloud SDK helpers + + +def install_cloud_sdk(session): + """Downloads and installs the Google Cloud SDK.""" + # Configure environment variables needed by the SDK. + # This sets the config root to the tests' config root. This prevents + # our tests from clobbering a developer's configuration when running + # these tests locally. + session.env[CLOUD_SDK_CONFIG_ENV] = str(CLOUD_SDK_ROOT) + # This set the $PATH for the subprocesses so they can find the gcloud + # executable. + session.env["PATH"] = ( + str(CLOUD_SDK_INSTALL_DIR.joinpath("bin")) + os.pathsep + os.environ["PATH"] + ) + + # If gcloud cli executable already exists, just update it. + if pathlib.Path(GCLOUD).exists(): + session.run(GCLOUD, "components", "update", "-q") + return + + tar_path = CLOUD_SDK_ROOT.joinpath(CLOUD_SDK_DIST_FILENAME) + + # Download the release. + session.run("wget", CLOUD_SDK_DOWNLOAD_URL, "-O", str(tar_path), silent=True) + + # Extract the release. + session.run("tar", "xzf", str(tar_path), "-C", str(CLOUD_SDK_ROOT)) + tar_path.unlink() + + # Run the install script. + session.run( + str(CLOUD_SDK_INSTALL_DIR.joinpath("install.sh")), + "--usage-reporting", + "false", + "--path-update", + "false", + "--command-completion", + "false", + silent=True, + ) + + +def copy_credentials(credentials_path): + """Copies credentials into the SDK root as the application default + credentials.""" + dest = CLOUD_SDK_ROOT.joinpath("application_default_credentials.json") + if dest.exists(): + dest.unlink() + shutil.copyfile(pathlib.Path(credentials_path), dest) + + +def configure_cloud_sdk(session, application_default_credentials, project=False): + """Installs and configures the Cloud SDK with the given application default + credentials. + + If project is True, then a project will be set in the active config. + If it is false, this will ensure no project is set. + """ + install_cloud_sdk(session) + + # Setup the service account as the default user account. This is + # needed for the project ID detection to work. Note that this doesn't + # change the application default credentials file, which is user + # credentials instead of service account credentials sometimes. + session.run( + GCLOUD, "auth", "activate-service-account", "--key-file", SERVICE_ACCOUNT_FILE + ) + + if project: + session.run(GCLOUD, "config", "set", "project", "example-project") + else: + session.run(GCLOUD, "config", "unset", "project") + + # Copy the credentials file to the config root. This is needed because + # unfortunately gcloud doesn't provide a clean way to tell it to use + # a particular set of credentials. However, this does verify that gcloud + # also considers the credentials valid by calling application-default + # print-access-token + session.run(copy_credentials, application_default_credentials) + + # Calling this forces the Cloud SDK to read the credentials we just wrote + # and obtain a new access token with those credentials. This validates + # that our credentials matches the format expected by gcloud. + # Silent is set to True to prevent leaking secrets in test logs. + session.run( + GCLOUD, "auth", "application-default", "print-access-token", silent=True + ) + + +# Test sesssions + +TEST_DEPENDENCIES_ASYNC = ["aiohttp", "pytest-asyncio", "nest-asyncio", "mock"] +TEST_DEPENDENCIES_SYNC = ["pytest", "requests", "mock", "pyjwt"] +PYTHON_VERSIONS_ASYNC = ["3.10"] +PYTHON_VERSIONS_SYNC = ["3.10"] + + +def default(session, *test_paths): + # replace 'session._runner.friendly_name' with + # session.name once nox has released a new version + # https://github.com/theacodes/nox/pull/386 + sponge_log = f"--junitxml=system_{str(session._runner.friendly_name)}_sponge_log.xml" + session.run( + "pytest", sponge_log, *test_paths, + ) + + +@nox.session(python=PYTHON_VERSIONS_SYNC) +def service_account_sync(session): + session.install(*TEST_DEPENDENCIES_SYNC) + session.install(LIBRARY_DIR) + default( + session, + "system_tests_sync/test_service_account.py", + *session.posargs, + ) + + +@nox.session(python=PYTHON_VERSIONS_SYNC) +def default_explicit_service_account(session): + session.env[EXPLICIT_CREDENTIALS_ENV] = SERVICE_ACCOUNT_FILE + session.env[EXPECT_PROJECT_ENV] = "1" + session.install(*TEST_DEPENDENCIES_SYNC) + session.install(LIBRARY_DIR) + default( + session, + "system_tests_sync/test_default.py", + "system_tests_sync/test_id_token.py", + *session.posargs, + ) + + +@nox.session(python=PYTHON_VERSIONS_SYNC) +def default_explicit_authorized_user(session): + session.env[EXPLICIT_CREDENTIALS_ENV] = AUTHORIZED_USER_FILE + session.install(*TEST_DEPENDENCIES_SYNC) + session.install(LIBRARY_DIR) + default( + session, + "system_tests_sync/test_default.py", + *session.posargs, + ) + + +@nox.session(python=PYTHON_VERSIONS_SYNC) +def default_explicit_authorized_user_explicit_project(session): + session.env[EXPLICIT_CREDENTIALS_ENV] = AUTHORIZED_USER_FILE + session.env[EXPLICIT_PROJECT_ENV] = "example-project" + session.env[EXPECT_PROJECT_ENV] = "1" + session.install(*TEST_DEPENDENCIES_SYNC) + session.install(LIBRARY_DIR) + default( + session, + "system_tests_sync/test_default.py", + *session.posargs, + ) + + +@nox.session(python=PYTHON_VERSIONS_SYNC) +def default_cloud_sdk_service_account(session): + configure_cloud_sdk(session, SERVICE_ACCOUNT_FILE) + session.env[EXPECT_PROJECT_ENV] = "1" + session.install(*TEST_DEPENDENCIES_SYNC) + session.install(LIBRARY_DIR) + default( + session, + "system_tests_sync/test_default.py", + *session.posargs, + ) + + +@nox.session(python=PYTHON_VERSIONS_SYNC) +def default_cloud_sdk_authorized_user(session): + configure_cloud_sdk(session, AUTHORIZED_USER_FILE) + session.install(*TEST_DEPENDENCIES_SYNC) + session.install(LIBRARY_DIR) + default( + session, + "system_tests_sync/test_default.py", + *session.posargs, + ) + + +@nox.session(python=PYTHON_VERSIONS_SYNC) +def default_cloud_sdk_authorized_user_configured_project(session): + configure_cloud_sdk(session, AUTHORIZED_USER_FILE, project=True) + session.env[EXPECT_PROJECT_ENV] = "1" + session.install(*TEST_DEPENDENCIES_SYNC) + session.install(LIBRARY_DIR) + default( + session, + "system_tests_sync/test_default.py", + *session.posargs, + ) + + +@nox.session(python=PYTHON_VERSIONS_SYNC) +def compute_engine(session): + session.install(*TEST_DEPENDENCIES_SYNC) + # unset Application Default Credentials so + # credentials are detected from environment + del session.virtualenv.env["GOOGLE_APPLICATION_CREDENTIALS"] + session.install(LIBRARY_DIR) + default( + session, + "system_tests_sync/test_compute_engine.py", + *session.posargs, + ) + + +@nox.session(python=PYTHON_VERSIONS_SYNC) +def grpc(session): + session.install(LIBRARY_DIR) + session.install("six") + session.install(*TEST_DEPENDENCIES_SYNC, "google-cloud-pubsub==1.7.2") + session.env[EXPLICIT_CREDENTIALS_ENV] = SERVICE_ACCOUNT_FILE + default( + session, + "system_tests_sync/test_grpc.py", + *session.posargs, + ) + + +@nox.session(python=PYTHON_VERSIONS_SYNC) +def requests(session): + session.install(LIBRARY_DIR) + session.install(*TEST_DEPENDENCIES_SYNC) + session.env[EXPLICIT_CREDENTIALS_ENV] = SERVICE_ACCOUNT_FILE + default( + session, + "system_tests_sync/test_requests.py", + *session.posargs, + ) + + +@nox.session(python=PYTHON_VERSIONS_SYNC) +def urllib3(session): + session.install(LIBRARY_DIR) + session.install(*TEST_DEPENDENCIES_SYNC) + session.env[EXPLICIT_CREDENTIALS_ENV] = SERVICE_ACCOUNT_FILE + default( + session, + "system_tests_sync/test_urllib3.py", + *session.posargs, + ) + + +@nox.session(python=PYTHON_VERSIONS_SYNC) +def mtls_http(session): + session.install(LIBRARY_DIR) + session.install(*TEST_DEPENDENCIES_SYNC, "pyopenssl") + session.env[EXPLICIT_CREDENTIALS_ENV] = SERVICE_ACCOUNT_FILE + default( + session, + "system_tests_sync/test_mtls_http.py", + *session.posargs, + ) + + +@nox.session(python=PYTHON_VERSIONS_ASYNC) +def external_accounts(session): + session.env[ALLOW_PLUGGABLE_ENV] = "1" + session.install( + *TEST_DEPENDENCIES_ASYNC, + LIBRARY_DIR, + "google-api-python-client", + ) + default( + session, + "system_tests_sync/test_external_accounts.py", + *session.posargs, + ) + + +@nox.session(python=PYTHON_VERSIONS_SYNC) +def downscoping(session): + session.install( + *TEST_DEPENDENCIES_SYNC, + LIBRARY_DIR, + "google-cloud-storage", + ) + default( + session, + "system_tests_sync/test_downscoping.py", + *session.posargs, + ) + + +# ASYNC SYSTEM TESTS + +@nox.session(python=PYTHON_VERSIONS_ASYNC) +def service_account_async(session): + session.install(*(TEST_DEPENDENCIES_SYNC + TEST_DEPENDENCIES_ASYNC)) + session.install(LIBRARY_DIR) + default( + session, + "system_tests_async/test_service_account.py", + *session.posargs, + ) + + +@nox.session(python=PYTHON_VERSIONS_ASYNC) +def default_explicit_service_account_async(session): + session.env[EXPLICIT_CREDENTIALS_ENV] = SERVICE_ACCOUNT_FILE + session.env[EXPECT_PROJECT_ENV] = "1" + session.install(*(TEST_DEPENDENCIES_SYNC + TEST_DEPENDENCIES_ASYNC)) + session.install(LIBRARY_DIR) + default( + session, + "system_tests_async/test_default.py", + "system_tests_async/test_id_token.py", + *session.posargs, + ) + + +@nox.session(python=PYTHON_VERSIONS_ASYNC) +def default_explicit_authorized_user_async(session): + session.env[EXPLICIT_CREDENTIALS_ENV] = AUTHORIZED_USER_FILE + session.install(*(TEST_DEPENDENCIES_SYNC + TEST_DEPENDENCIES_ASYNC)) + session.install(LIBRARY_DIR) + default( + session, + "system_tests_async/test_default.py", + *session.posargs, + ) + + +@nox.session(python=PYTHON_VERSIONS_ASYNC) +def default_explicit_authorized_user_explicit_project_async(session): + session.env[EXPLICIT_CREDENTIALS_ENV] = AUTHORIZED_USER_FILE + session.env[EXPLICIT_PROJECT_ENV] = "example-project" + session.env[EXPECT_PROJECT_ENV] = "1" + session.install(*(TEST_DEPENDENCIES_SYNC + TEST_DEPENDENCIES_ASYNC)) + session.install(LIBRARY_DIR) + default( + session, + "system_tests_async/test_default.py", + *session.posargs, + ) + + +@nox.session(python=PYTHON_VERSIONS_ASYNC) +def default_cloud_sdk_service_account_async(session): + configure_cloud_sdk(session, SERVICE_ACCOUNT_FILE) + session.env[EXPECT_PROJECT_ENV] = "1" + session.install(*(TEST_DEPENDENCIES_SYNC + TEST_DEPENDENCIES_ASYNC)) + session.install(LIBRARY_DIR) + default( + session, + "system_tests_async/test_default.py", + *session.posargs, + ) + + +@nox.session(python=PYTHON_VERSIONS_ASYNC) +def default_cloud_sdk_authorized_user_async(session): + configure_cloud_sdk(session, AUTHORIZED_USER_FILE) + session.install(*(TEST_DEPENDENCIES_SYNC + TEST_DEPENDENCIES_ASYNC)) + session.install(LIBRARY_DIR) + default( + session, + "system_tests_async/test_default.py", + *session.posargs, + ) + + +@nox.session(python=PYTHON_VERSIONS_ASYNC) +def default_cloud_sdk_authorized_user_configured_project_async(session): + configure_cloud_sdk(session, AUTHORIZED_USER_FILE, project=True) + session.env[EXPECT_PROJECT_ENV] = "1" + session.install(*(TEST_DEPENDENCIES_SYNC + TEST_DEPENDENCIES_ASYNC)) + session.install(LIBRARY_DIR) + default( + session, + "system_tests_async/test_default.py", + *session.posargs, + ) diff --git a/packages/google-auth/system_tests/secrets.tar.enc b/packages/google-auth/system_tests/secrets.tar.enc new file mode 100644 index 000000000000..4b1108092b18 Binary files /dev/null and b/packages/google-auth/system_tests/secrets.tar.enc differ diff --git a/packages/google-auth/system_tests/system_tests_async/__init__.py b/packages/google-auth/system_tests/system_tests_async/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/google-auth/system_tests/system_tests_async/conftest.py b/packages/google-auth/system_tests/system_tests_async/conftest.py new file mode 100644 index 000000000000..8ab2ff0293f6 --- /dev/null +++ b/packages/google-auth/system_tests/system_tests_async/conftest.py @@ -0,0 +1,116 @@ +# 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 json +import os + +from google.auth import _helpers +import google.auth.transport.requests +import google.auth.transport.urllib3 +import pytest +import pytest_asyncio +import requests +import urllib3 + +import aiohttp +from google.auth.transport import _aiohttp_requests as aiohttp_requests +from system_tests.system_tests_sync import conftest as sync_conftest + + +TOKEN_INFO_URL = "https://www.googleapis.com/oauth2/v3/tokeninfo" + + +@pytest_asyncio.fixture +def service_account_file(): + """The full path to a valid service account key file.""" + yield sync_conftest.SERVICE_ACCOUNT_FILE + + +@pytest_asyncio.fixture +def impersonated_service_account_file(): + """The full path to a valid service account key file.""" + yield sync_conftest.IMPERSONATED_SERVICE_ACCOUNT_FILE + + +@pytest_asyncio.fixture +def authorized_user_file(): + """The full path to a valid authorized user file.""" + yield sync_conftest.AUTHORIZED_USER_FILE + + +@pytest_asyncio.fixture +async def aiohttp_session(): + async with aiohttp.ClientSession(auto_decompress=False) as session: + yield session + + +@pytest_asyncio.fixture(params=["aiohttp"]) +async def http_request(request, aiohttp_session): + """A transport.request object.""" + yield aiohttp_requests.Request(aiohttp_session) + + +@pytest_asyncio.fixture +async def token_info(http_request): + """Returns a function that obtains OAuth2 token info.""" + + async def _token_info(access_token=None, id_token=None): + query_params = {} + + if access_token is not None: + query_params["access_token"] = access_token + elif id_token is not None: + query_params["id_token"] = id_token + else: + raise ValueError("No token specified.") + + url = _helpers.update_query(sync_conftest.TOKEN_INFO_URL, query_params) + + response = await http_request(url=url, method="GET") + + data = await response.content() + + return json.loads(data.decode("utf-8")) + + yield _token_info + + +@pytest_asyncio.fixture +async def verify_refresh(http_request): + """Returns a function that verifies that credentials can be refreshed.""" + + async def _verify_refresh(credentials): + if credentials.requires_scopes: + credentials = credentials.with_scopes(["email", "profile"]) + + await credentials.refresh(http_request) + + assert credentials.token + assert credentials.valid + + yield _verify_refresh + + +def verify_environment(): + """Checks to make sure that requisite data files are available.""" + if not os.path.isdir(sync_conftest.DATA_DIR): + raise EnvironmentError( + "In order to run system tests, test data must exist in " + "system_tests/data. See CONTRIBUTING.rst for details." + ) + + +def pytest_configure(config): + """Pytest hook that runs before Pytest collects any tests.""" + verify_environment() diff --git a/packages/google-auth/system_tests/system_tests_async/test_default.py b/packages/google-auth/system_tests/system_tests_async/test_default.py new file mode 100644 index 000000000000..32299c059a92 --- /dev/null +++ b/packages/google-auth/system_tests/system_tests_async/test_default.py @@ -0,0 +1,29 @@ +# 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 os +import pytest + +from google.auth import _default_async + +EXPECT_PROJECT_ID = os.environ.get("EXPECT_PROJECT_ID") + +@pytest.mark.asyncio +async def test_application_default_credentials(verify_refresh): + credentials, project_id = _default_async.default_async() + + if EXPECT_PROJECT_ID is not None: + assert project_id is not None + + await verify_refresh(credentials) diff --git a/packages/google-auth/system_tests/system_tests_async/test_id_token.py b/packages/google-auth/system_tests/system_tests_async/test_id_token.py new file mode 100644 index 000000000000..a21b137b65b0 --- /dev/null +++ b/packages/google-auth/system_tests/system_tests_async/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_async + +@pytest.mark.asyncio +async def test_fetch_id_token(http_request): + audience = "https://pubsub.googleapis.com" + token = await google.oauth2._id_token_async.fetch_id_token(http_request, audience) + + _, payload, _, _ = jwt._unverified_decode(token) + assert payload["aud"] == audience diff --git a/packages/google-auth/system_tests/system_tests_async/test_service_account.py b/packages/google-auth/system_tests/system_tests_async/test_service_account.py new file mode 100644 index 000000000000..c1c16ccd7309 --- /dev/null +++ b/packages/google-auth/system_tests/system_tests_async/test_service_account.py @@ -0,0 +1,53 @@ +# 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 _helpers +from google.auth import exceptions +from google.auth import iam +from google.oauth2 import _service_account_async + + +@pytest.fixture +def credentials(service_account_file): + yield _service_account_async.Credentials.from_service_account_file(service_account_file) + + +@pytest.mark.asyncio +async def test_refresh_no_scopes(http_request, credentials): + """ + We expect the http request to refresh credentials + without scopes provided to throw an error. + """ + with pytest.raises(exceptions.RefreshError): + await credentials.refresh(http_request) + +@pytest.mark.asyncio +async def test_refresh_success(http_request, credentials, token_info): + credentials = credentials.with_scopes(["email", "profile"]) + await credentials.refresh(http_request) + + assert credentials.token + + info = await token_info(credentials.token) + + assert info["email"] == credentials.service_account_email + info_scopes = _helpers.string_to_scopes(info["scope"]) + assert set(info_scopes) == set( + [ + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + ] + ) diff --git a/packages/google-auth/system_tests/system_tests_sync/.gitignore b/packages/google-auth/system_tests/system_tests_sync/.gitignore new file mode 100644 index 000000000000..be605500b184 --- /dev/null +++ b/packages/google-auth/system_tests/system_tests_sync/.gitignore @@ -0,0 +1,2 @@ +data +secrets.tar \ No newline at end of file diff --git a/packages/google-auth/system_tests/system_tests_sync/__init__.py b/packages/google-auth/system_tests/system_tests_sync/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/google-auth/system_tests/system_tests_sync/conftest.py b/packages/google-auth/system_tests/system_tests_sync/conftest.py new file mode 100644 index 000000000000..16caa659ee61 --- /dev/null +++ b/packages/google-auth/system_tests/system_tests_sync/conftest.py @@ -0,0 +1,141 @@ +# Copyright 2016 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 json +import os + +from google.auth import _helpers +import google.auth.transport.requests +import google.auth.transport.urllib3 +import pytest +import requests +import urllib3 + + +HERE = os.path.dirname(__file__) +DATA_DIR = os.path.join(HERE, "../data") +IMPERSONATED_SERVICE_ACCOUNT_FILE = os.path.join( + DATA_DIR, "impersonated_service_account.json" +) +SERVICE_ACCOUNT_FILE = os.path.join(DATA_DIR, "service_account.json") +URLLIB3_HTTP = urllib3.PoolManager(retries=False) +REQUESTS_SESSION = requests.Session() +REQUESTS_SESSION.verify = False +TOKEN_INFO_URL = "https://www.googleapis.com/oauth2/v3/tokeninfo" + + +@pytest.fixture +def service_account_file(): + """The full path to a valid service account key file.""" + yield SERVICE_ACCOUNT_FILE + + +@pytest.fixture +def impersonated_service_account_file(): + """The full path to a valid service account key file.""" + yield IMPERSONATED_SERVICE_ACCOUNT_FILE + + +@pytest.fixture +def authorized_user_file(): + """The full path to a valid authorized user file.""" + yield AUTHORIZED_USER_FILE + + +@pytest.fixture(params=["urllib3", "requests"]) +def request_type(request): + yield request.param + + +@pytest.fixture +def http_request(request_type): + """A transport.request object.""" + if request_type == "urllib3": + yield google.auth.transport.urllib3.Request(URLLIB3_HTTP) + elif request_type == "requests": + yield google.auth.transport.requests.Request(REQUESTS_SESSION) + + +@pytest.fixture +def authenticated_request(request_type): + """A transport.request object that takes credentials""" + if request_type == "urllib3": + + def wrapper(credentials): + return google.auth.transport.urllib3.AuthorizedHttp( + credentials, http=URLLIB3_HTTP + ).request + + yield wrapper + elif request_type == "requests": + + def wrapper(credentials): + session = google.auth.transport.requests.AuthorizedSession(credentials) + session.verify = False + return google.auth.transport.requests.Request(session) + + yield wrapper + + +@pytest.fixture +def token_info(http_request): + """Returns a function that obtains OAuth2 token info.""" + + def _token_info(access_token=None, id_token=None): + query_params = {} + + if access_token is not None: + query_params["access_token"] = access_token + elif id_token is not None: + query_params["id_token"] = id_token + else: + raise ValueError("No token specified.") + + url = _helpers.update_query(TOKEN_INFO_URL, query_params) + + response = http_request(url=url, method="GET") + + return json.loads(response.data.decode("utf-8")) + + yield _token_info + + +@pytest.fixture +def verify_refresh(http_request): + """Returns a function that verifies that credentials can be refreshed.""" + + def _verify_refresh(credentials): + if credentials.requires_scopes: + credentials = credentials.with_scopes(["email", "profile"]) + + credentials.refresh(http_request) + + assert credentials.token + assert credentials.valid + + yield _verify_refresh + + +def verify_environment(): + """Checks to make sure that requisite data files are available.""" + if not os.path.isdir(DATA_DIR): + raise EnvironmentError( + "In order to run system tests, test data must exist in " + "system_tests/data. See CONTRIBUTING.rst for details." + ) + + +def pytest_configure(config): + """Pytest hook that runs before Pytest collects any tests.""" + verify_environment() diff --git a/packages/google-auth/system_tests/system_tests_sync/secrets.tar.enc b/packages/google-auth/system_tests/system_tests_sync/secrets.tar.enc new file mode 100644 index 000000000000..29e06923f0f0 Binary files /dev/null and b/packages/google-auth/system_tests/system_tests_sync/secrets.tar.enc differ diff --git a/packages/google-auth/system_tests/system_tests_sync/test_compute_engine.py b/packages/google-auth/system_tests/system_tests_sync/test_compute_engine.py new file mode 100644 index 000000000000..1e0eaf11db9f --- /dev/null +++ b/packages/google-auth/system_tests/system_tests_sync/test_compute_engine.py @@ -0,0 +1,75 @@ +# Copyright 2016 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. + +from datetime import datetime + +import pytest + +import google.auth +from google.auth import compute_engine +from google.auth import _helpers +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) +def check_gce_environment(http_request): + try: + _metadata.get_service_account_info(http_request) + except exceptions.TransportError: + pytest.skip("Compute Engine metadata service is not available.") + + +def test_refresh(http_request, token_info): + credentials = compute_engine.Credentials() + + credentials.refresh(http_request) + + assert credentials.token is not None + assert credentials.service_account_email is not None + + info = token_info(credentials.token) + info_scopes = _helpers.string_to_scopes(info["scope"]) + assert set(info_scopes) == set(credentials.scopes) + + +def test_default(verify_refresh): + credentials, project_id = google.auth.default() + + assert project_id is not None + assert isinstance(credentials, compute_engine.Credentials) + verify_refresh(credentials) + + +def test_id_token_from_metadata(http_request): + credentials = compute_engine.IDTokenCredentials( + http_request, AUDIENCE, use_metadata_identity_endpoint=True + ) + credentials.refresh(http_request) + + _, payload, _, _ = jwt._unverified_decode(credentials.token) + assert credentials.valid + assert payload["aud"] == AUDIENCE + assert datetime.fromtimestamp(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/packages/google-auth/system_tests/system_tests_sync/test_default.py b/packages/google-auth/system_tests/system_tests_sync/test_default.py new file mode 100644 index 000000000000..560ab3284924 --- /dev/null +++ b/packages/google-auth/system_tests/system_tests_sync/test_default.py @@ -0,0 +1,28 @@ +# Copyright 2016 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 os + +import google.auth + +EXPECT_PROJECT_ID = os.environ.get("EXPECT_PROJECT_ID") + + +def test_application_default_credentials(verify_refresh): + credentials, project_id = google.auth.default() + + if EXPECT_PROJECT_ID is not None: + assert project_id is not None + + verify_refresh(credentials) diff --git a/packages/google-auth/system_tests/system_tests_sync/test_downscoping.py b/packages/google-auth/system_tests/system_tests_sync/test_downscoping.py new file mode 100644 index 000000000000..fdb4efaed02e --- /dev/null +++ b/packages/google-auth/system_tests/system_tests_sync/test_downscoping.py @@ -0,0 +1,162 @@ +# Copyright 2021 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 re +import uuid + +import google.auth + +from google.auth import downscoped +from google.auth.transport import requests +from google.cloud import exceptions +from google.cloud import storage +from google.oauth2 import credentials + +import pytest + + # The object prefix used to test access to files beginning with this prefix. +_OBJECT_PREFIX = "customer-a" +# The object name of the object inaccessible by the downscoped token. +_ACCESSIBLE_OBJECT_NAME = "{0}-data.txt".format(_OBJECT_PREFIX) +# The content of the object accessible by the downscoped token. +_ACCESSIBLE_CONTENT = "hello world" +# The content of the object inaccessible by the downscoped token. +_INACCESSIBLE_CONTENT = "secret content" +# The object name of the object inaccessible by the downscoped token. +_INACCESSIBLE_OBJECT_NAME = "other-customer-data.txt" + + +@pytest.fixture(scope="module") +def temp_bucket(): + """Yields a bucket that is deleted after the test completes.""" + bucket = None + while bucket is None or bucket.exists(): + bucket_name = "auth-python-downscope-test-{}".format(uuid.uuid4()) + bucket = storage.Client().bucket(bucket_name) + bucket = storage.Client().create_bucket(bucket.name) + yield bucket + bucket.delete(force=True) + + +@pytest.fixture(scope="module") +def temp_blobs(temp_bucket): + """Yields two blobs that are deleted after the test completes.""" + bucket = temp_bucket + # Downscoped tokens will have readonly access to this blob. + accessible_blob = bucket.blob(_ACCESSIBLE_OBJECT_NAME) + accessible_blob.upload_from_string(_ACCESSIBLE_CONTENT) + # Downscoped tokens will have no access to this blob. + inaccessible_blob = bucket.blob(_INACCESSIBLE_OBJECT_NAME) + inaccessible_blob.upload_from_string(_INACCESSIBLE_CONTENT) + yield (accessible_blob, inaccessible_blob) + bucket.delete_blobs([accessible_blob, inaccessible_blob]) + + +def get_token_from_broker(bucket_name, object_prefix): + """Simulates token broker generating downscoped tokens for specified bucket. + + Args: + bucket_name (str): The name of the Cloud Storage bucket. + object_prefix (str): The prefix string of the object name. This is used + to ensure access is restricted to only objects starting with this + prefix string. + + Returns: + Tuple[str, datetime.datetime]: The downscoped access token and its expiry date. + """ + # Initialize the Credential Access Boundary rules. + available_resource = "//storage.googleapis.com/projects/_/buckets/{0}".format(bucket_name) + # Downscoped credentials will have readonly access to the resource. + available_permissions = ["inRole:roles/storage.objectViewer"] + # Only objects starting with the specified prefix string in the object name + # will be allowed read access. + availability_expression = ( + "resource.name.startsWith('projects/_/buckets/{0}/objects/{1}')".format(bucket_name, object_prefix) + ) + availability_condition = downscoped.AvailabilityCondition(availability_expression) + # Define the single access boundary rule using the above properties. + rule = downscoped.AccessBoundaryRule( + available_resource=available_resource, + available_permissions=available_permissions, + availability_condition=availability_condition, + ) + # Define the Credential Access Boundary with all the relevant rules. + credential_access_boundary = downscoped.CredentialAccessBoundary(rules=[rule]) + + # Retrieve the source credentials via ADC. + source_credentials, _ = google.auth.default() + if source_credentials.requires_scopes: + source_credentials = source_credentials.with_scopes( + ["https://www.googleapis.com/auth/cloud-platform"] + ) + + # Create the downscoped credentials. + downscoped_credentials = downscoped.Credentials( + source_credentials=source_credentials, + credential_access_boundary=credential_access_boundary, + ) + + # Refresh the tokens. + downscoped_credentials.refresh(requests.Request()) + + # These values will need to be passed to the token consumer. + access_token = downscoped_credentials.token + expiry = downscoped_credentials.expiry + return (access_token, expiry) + + +def test_downscoping(temp_blobs): + """Tests token consumer access to cloud storage using downscoped tokens. + + Args: + temp_blobs (Tuple[google.cloud.storage.blob.Blob, ...]): The temporarily + created test cloud storage blobs (one readonly accessible, the other + not). + """ + accessible_blob, inaccessible_blob = temp_blobs + bucket_name = accessible_blob.bucket.name + # Create the OAuth credentials from the downscoped token and pass a + # refresh handler to handle token expiration. We are passing a + # refresh_handler instead of a one-time access token/expiry pair. + # This will allow testing this on-demand method for getting access tokens. + def refresh_handler(request, scopes=None): + # Get readonly access tokens to objects with accessible prefix in + # the temporarily created bucket. + return get_token_from_broker(bucket_name, _OBJECT_PREFIX) + + creds = credentials.Credentials( + None, + scopes=["https://www.googleapis.com/auth/cloud-platform"], + refresh_handler=refresh_handler, + ) + + # Initialize a Cloud Storage client with the oauth2 credentials. + storage_client = storage.Client(credentials=creds) + + # Test read access succeeds to accessible blob. + bucket = storage_client.bucket(bucket_name) + blob = bucket.blob(accessible_blob.name) + assert blob.download_as_bytes().decode("utf-8") == _ACCESSIBLE_CONTENT + + # Test write access fails. + with pytest.raises(exceptions.Forbidden) as excinfo: + blob.upload_from_string("Write operations are not allowed") + + assert excinfo.match(r"does not have storage.objects.create access") + + # Test read access fails to inaccessible blob. + with pytest.raises(exceptions.Forbidden) as excinfo: + bucket.blob(inaccessible_blob.name).download_as_bytes() + + assert excinfo.match(r"does not have storage.objects.get access") diff --git a/packages/google-auth/system_tests/system_tests_sync/test_external_accounts.py b/packages/google-auth/system_tests/system_tests_sync/test_external_accounts.py new file mode 100644 index 000000000000..837d0064b796 --- /dev/null +++ b/packages/google-auth/system_tests/system_tests_sync/test_external_accounts.py @@ -0,0 +1,418 @@ +# Copyright 2021 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. + +# Prerequisites: +# Make sure to run the setup in scripts/setup_external_accounts.sh +# and copy the logged constant strings (_AUDIENCE_OIDC, _AUDIENCE_AWS) +# into this file before running this test suite. +# Once that is done, this test can be run indefinitely. +# +# The only requirement for this test suite to run is to set the environment +# variable GOOGLE_APPLICATION_CREDENTIALS to point to the expected service +# account keys whose email is referred to in the setup script. +# +# This script follows the following logic. +# OIDC provider (file-sourced and url-sourced credentials): +# Use the service account keys to generate a Google ID token using the +# iamcredentials generateIdToken API, using the default STS audience. +# This will use the service account client ID as the sub field of the token. +# This OIDC token will be used as the external subject token to be exchanged +# for a Google access token via GCP STS endpoint and then to impersonate the +# original service account key. + + +import datetime +import json +import os +import socket +from tempfile import NamedTemporaryFile +import threading +import time + +import sys +import google.auth +from google.auth import _helpers +from googleapiclient import discovery +from http.server import BaseHTTPRequestHandler +from http.server import HTTPServer +from google.oauth2 import service_account +import pytest +from mock import patch + +# Populate values from the output of scripts/setup_external_accounts.sh. +_AUDIENCE_OIDC = "//iam.googleapis.com/projects/79992041559/locations/global/workloadIdentityPools/pool-73wslmxn/providers/oidc-73wslmxn" +_AUDIENCE_AWS = "//iam.googleapis.com/projects/79992041559/locations/global/workloadIdentityPools/pool-73wslmxn/providers/aws-73wslmxn" +_ROLE_AWS = "arn:aws:iam::077071391996:role/ci-python-test" + + +def dns_access_direct(request, project_id): + # First, get the default credentials. + credentials, _ = google.auth.default( + scopes=["https://www.googleapis.com/auth/cloud-platform.read-only"], + request=request, + ) + + # Apply the default credentials to the headers to make the request. + headers = {} + credentials.apply(headers) + response = request( + url="https://dns.googleapis.com/dns/v1/projects/{}".format(project_id), + headers=headers, + ) + + if response.status == 200: + return response.data + + +def dns_access_client_library(_, project_id): + service = discovery.build("dns", "v1") + request = service.projects().get(project=project_id) + return request.execute() + + +@pytest.fixture(params=[dns_access_direct, dns_access_client_library]) +def dns_access(request, http_request, service_account_info): + # Fill in the fixtures on the functions, + # so that we don't have to fill in the parameters manually. + def wrapper(): + return request.param(http_request, service_account_info["project_id"]) + + yield wrapper + + +@pytest.fixture +def oidc_credentials(service_account_file, http_request): + result = service_account.IDTokenCredentials.from_service_account_file( + service_account_file, target_audience=_AUDIENCE_OIDC + ) + result.refresh(http_request) + yield result + + +@pytest.fixture +def service_account_info(service_account_file): + with open(service_account_file) as f: + yield json.load(f) + + +@pytest.fixture +def aws_oidc_credentials( + service_account_file, service_account_info, authenticated_request +): + credentials = service_account.Credentials.from_service_account_file( + service_account_file, scopes=["https://www.googleapis.com/auth/cloud-platform"] + ) + result = authenticated_request(credentials)( + url="https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{}:generateIdToken".format( + service_account_info["client_email"] + ), + method="POST", + body=json.dumps( + {"audience": service_account_info["client_id"], "includeEmail": True} + ), + ) + assert result.status == 200 + + yield json.loads(result.data)["token"] + + +# Our external accounts tests involve setting up some preconditions, setting a +# credential file, and then making sure that our client libraries can work with +# the set credentials. +def get_project_dns(dns_access, credential_data): + with NamedTemporaryFile() as credfile: + credfile.write(json.dumps(credential_data).encode("utf-8")) + credfile.flush() + + with patch.dict(os.environ, {"GOOGLE_APPLICATION_CREDENTIALS": credfile.name}): + # If our setup and credential file are correct, + # discovery.build should be able to establish these as the default credentials. + return dns_access() + + +def get_xml_value_by_tagname(data, tagname): + startIndex = data.index("<{}>".format(tagname)) + if startIndex >= 0: + endIndex = data.index("".format(tagname), startIndex) + if endIndex > startIndex: + return data[startIndex + len(tagname) + 2 : endIndex] + + +# This test makes sure that setting an accesible credential file +# works to allow access to Google resources. +def test_file_based_external_account(oidc_credentials, dns_access): + with NamedTemporaryFile() as tmpfile: + tmpfile.write(oidc_credentials.token.encode("utf-8")) + tmpfile.flush() + + assert get_project_dns( + dns_access, + { + "type": "external_account", + "audience": _AUDIENCE_OIDC, + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + "token_url": "https://sts.googleapis.com/v1/token", + "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{}:generateAccessToken".format( + oidc_credentials.service_account_email + ), + "credential_source": { + "file": tmpfile.name, + }, + }, + ) + + +# This test makes sure that setting a token lifetime works +# for service account impersonation. +def test_file_based_external_account_with_configure_token_lifetime( + oidc_credentials, dns_access +): + with NamedTemporaryFile() as tmpfile: + tmpfile.write(oidc_credentials.token.encode("utf-8")) + tmpfile.flush() + + assert get_project_dns( + dns_access, + { + "type": "external_account", + "audience": _AUDIENCE_OIDC, + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + "token_url": "https://sts.googleapis.com/v1/token", + "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{}:generateAccessToken".format( + oidc_credentials.service_account_email + ), + "service_account_impersonation": { + "token_lifetime_seconds": 2800, + }, + "credential_source": { + "file": tmpfile.name, + }, + }, + ) + + +def test_configurable_token_lifespan(oidc_credentials, http_request): + TOKEN_LIFETIME_SECONDS = 2800 + BUFFER_SECONDS = 5 + + def check_impersonation_expiration(): + # First, get the default credentials. + credentials, _ = google.auth.default( + scopes=["https://www.googleapis.com/auth/cloud-platform.read-only"], + request=http_request, + ) + + utcmax = _helpers.utcnow() + datetime.timedelta(seconds=TOKEN_LIFETIME_SECONDS) + utcmin = utcmax - datetime.timedelta(seconds=BUFFER_SECONDS) + assert utcmin < credentials._impersonated_credentials.expiry <= utcmax + + return True + + with NamedTemporaryFile() as tmpfile: + tmpfile.write(oidc_credentials.token.encode("utf-8")) + tmpfile.flush() + + assert get_project_dns( + check_impersonation_expiration, + { + "type": "external_account", + "audience": _AUDIENCE_OIDC, + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + "token_url": "https://sts.googleapis.com/v1/token", + "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{}:generateAccessToken".format( + oidc_credentials.service_account_email + ), + "service_account_impersonation": { + "token_lifetime_seconds": TOKEN_LIFETIME_SECONDS, + }, + "credential_source": { + "file": tmpfile.name, + }, + }, + ) + + +# This test makes sure that setting up an http server to provide credentials +# works to allow access to Google resources. +def test_url_based_external_account(dns_access, oidc_credentials, service_account_info): + class TestResponseHandler(BaseHTTPRequestHandler): + def do_GET(self): + if self.headers["my-header"] != "expected-value": + self.send_response(400) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write( + json.dumps({"error": "missing header"}).encode("utf-8") + ) + elif self.path != "/token": + self.send_response(400) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write( + json.dumps({"error": "incorrect token path"}).encode("utf-8") + ) + else: + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write( + json.dumps({"access_token": oidc_credentials.token}).encode("utf-8") + ) + + class TestHTTPServer(HTTPServer, object): + def __init__(self): + self.port = self._find_open_port() + super(TestHTTPServer, self).__init__(("", self.port), TestResponseHandler) + + @staticmethod + def _find_open_port(): + s = socket.socket() + s.bind(("", 0)) + return s.getsockname()[1] + + # This makes sure that the server gets shut down when this variable leaves its "with" block + # The python3 HttpServer has __enter__ and __exit__ methods, but python2 does not. + # By redefining the __enter__ and __exit__ methods, we ensure that python2 and python3 act similarly + def __exit__(self, *args): + self.shutdown() + + def __enter__(self): + return self + + with TestHTTPServer() as server: + threading.Thread(target=server.serve_forever).start() + + assert get_project_dns( + dns_access, + { + "type": "external_account", + "audience": _AUDIENCE_OIDC, + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + "token_url": "https://sts.googleapis.com/v1/token", + "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{}:generateAccessToken".format( + oidc_credentials.service_account_email + ), + "credential_source": { + "url": "http://localhost:{}/token".format(server.port), + "headers": {"my-header": "expected-value"}, + "format": { + "type": "json", + "subject_token_field_name": "access_token", + }, + }, + }, + ) + + +# AWS provider tests for AWS credentials +# The test suite will also run tests for AWS credentials. This works as +# follows. (Note prequisite setup is needed. This is documented in +# setup_external_accounts.sh). +# - iamcredentials:generateIdToken is used to generate a Google ID token using +# the service account access token. The service account client_id is used as +# audience. +# - AWS STS AssumeRoleWithWebIdentity API is used to exchange this token for +# temporary AWS security credentials for a specified AWS ARN role. +# - AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN +# environment variables are set using these credentials before the test is +# run simulating an AWS VM. +# - The test can now be run. +def test_aws_based_external_account( + aws_oidc_credentials, service_account_info, dns_access, http_request +): + # temporarily disable this test as we investigate why it fails + # https://github.com/googleapis/google-auth-library-python/issues/1279 + return + response = http_request( + url=( + "https://sts.amazonaws.com/" + "?Action=AssumeRoleWithWebIdentity" + "&Version=2011-06-15" + "&DurationSeconds=3600" + "&RoleSessionName=python-test" + "&RoleArn={}" + "&WebIdentityToken={}" + ).format(_ROLE_AWS, aws_oidc_credentials) + ) + assert response.status == 200 + + # The returned data is in XML, but loading an XML parser would be overkill. + # Searching the return text manually for the start and finish tag. + data = response.data.decode("utf-8") + + with patch.dict( + os.environ, + { + "AWS_REGION": "us-east-2", + "AWS_ACCESS_KEY_ID": get_xml_value_by_tagname(data, "AccessKeyId"), + "AWS_SECRET_ACCESS_KEY": get_xml_value_by_tagname(data, "SecretAccessKey"), + "AWS_SESSION_TOKEN": get_xml_value_by_tagname(data, "SessionToken"), + }, + ): + assert get_project_dns( + dns_access, + { + "type": "external_account", + "audience": _AUDIENCE_AWS, + "subject_token_type": "urn:ietf:params:aws:token-type:aws4_request", + "token_url": "https://sts.googleapis.com/v1/token", + "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{}:generateAccessToken".format( + service_account_info["client_email"] + ), + "credential_source": { + "environment_id": "aws1", + "regional_cred_verification_url": "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15", + }, + }, + ) + + +# This test makes sure that setting up an executable to provide credentials +# works to allow access to Google resources. +def test_pluggable_external_account(oidc_credentials, service_account_info, dns_access): + now = datetime.datetime.now() + unix_seconds = time.mktime(now.timetuple()) + expiration_time = (unix_seconds + 1 * 60 * 60) * 1000 + credential = { + "success": True, + "version": 1, + "expiration_time": expiration_time, + "token_type": "urn:ietf:params:oauth:token-type:jwt", + "id_token": oidc_credentials.token, + } + + tmpfile = NamedTemporaryFile(delete=True) + with open(tmpfile.name, "w") as f: + f.write("#!/bin/bash\n") + f.write('echo "{}"\n'.format(json.dumps(credential).replace('"', '\\"'))) + tmpfile.file.close() + + os.chmod(tmpfile.name, 0o777) + assert get_project_dns( + dns_access, + { + "type": "external_account", + "audience": _AUDIENCE_OIDC, + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + "token_url": "https://sts.googleapis.com/v1/token", + "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{}:generateAccessToken".format( + oidc_credentials.service_account_email + ), + "credential_source": { + "executable": { + "command": tmpfile.name, + } + }, + }, + ) diff --git a/packages/google-auth/system_tests/system_tests_sync/test_grpc.py b/packages/google-auth/system_tests/system_tests_sync/test_grpc.py new file mode 100644 index 000000000000..7f548ec0e9b3 --- /dev/null +++ b/packages/google-auth/system_tests/system_tests_sync/test_grpc.py @@ -0,0 +1,93 @@ +# Copyright 2016 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 google.auth +import google.auth.credentials +import google.auth.jwt +import google.auth.transport.grpc +from google.oauth2 import service_account + +from google.cloud import pubsub_v1 + + +def test_grpc_request_with_regular_credentials(http_request): + credentials, project_id = google.auth.default() + credentials = google.auth.credentials.with_scopes_if_required( + credentials, scopes=["https://www.googleapis.com/auth/pubsub"] + ) + + + # Create a pub/sub client. + client = pubsub_v1.PublisherClient(credentials=credentials) + + # list the topics and drain the iterator to test that an authorized API + # call works. + list_topics_iter = client.list_topics(project="projects/{}".format(project_id)) + list(list_topics_iter) + + +def test_grpc_request_with_regular_credentials_and_self_signed_jwt(http_request): + credentials, project_id = google.auth.default() + + # At the time this test is being written, there are no GAPIC libraries + # that will trigger the self-signed JWT flow. Manually create the self-signed + # jwt on the service account credential to check that the request + # succeeds. + credentials = credentials.with_scopes( + scopes=[], default_scopes=["https://www.googleapis.com/auth/pubsub"] + ) + credentials._create_self_signed_jwt(audience="https://pubsub.googleapis.com/") + + # Create a pub/sub client. + client = pubsub_v1.PublisherClient(credentials=credentials) + + # list the topics and drain the iterator to test that an authorized API + # call works. + list_topics_iter = client.list_topics(project="projects/{}".format(project_id)) + list(list_topics_iter) + + # Check that self-signed JWT was created and is being used + assert credentials._jwt_credentials is not None + assert credentials._jwt_credentials.token == credentials.token + + +def test_grpc_request_with_jwt_credentials(): + credentials, project_id = google.auth.default() + audience = "https://pubsub.googleapis.com/google.pubsub.v1.Publisher" + credentials = google.auth.jwt.Credentials.from_signing_credentials( + credentials, audience=audience + ) + + # Create a pub/sub client. + client = pubsub_v1.PublisherClient(credentials=credentials) + + # list the topics and drain the iterator to test that an authorized API + # call works. + list_topics_iter = client.list_topics(project="projects/{}".format(project_id)) + list(list_topics_iter) + + +def test_grpc_request_with_on_demand_jwt_credentials(): + credentials, project_id = google.auth.default() + credentials = google.auth.jwt.OnDemandCredentials.from_signing_credentials( + credentials + ) + + # Create a pub/sub client. + client = pubsub_v1.PublisherClient(credentials=credentials) + + # list the topics and drain the iterator to test that an authorized API + # call works. + list_topics_iter = client.list_topics(project="projects/{}".format(project_id)) + list(list_topics_iter) diff --git a/packages/google-auth/system_tests/system_tests_sync/test_id_token.py b/packages/google-auth/system_tests/system_tests_sync/test_id_token.py new file mode 100644 index 000000000000..b07cefc184c9 --- /dev/null +++ b/packages/google-auth/system_tests/system_tests_sync/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 diff --git a/packages/google-auth/system_tests/system_tests_sync/test_impersonated_credentials.py b/packages/google-auth/system_tests/system_tests_sync/test_impersonated_credentials.py new file mode 100644 index 000000000000..6689e8943b2a --- /dev/null +++ b/packages/google-auth/system_tests/system_tests_sync/test_impersonated_credentials.py @@ -0,0 +1,99 @@ +# 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 json +import pytest + +import google.oauth2.credentials +from google.oauth2 import service_account +import google.auth.impersonated_credentials +from google.auth import _helpers + + +GOOGLE_OAUTH2_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token" + + +@pytest.fixture +def service_account_credentials(service_account_file): + yield service_account.Credentials.from_service_account_file(service_account_file) + + +@pytest.fixture +def impersonated_service_account_credentials(impersonated_service_account_file): + yield service_account.Credentials.from_service_account_file( + impersonated_service_account_file + ) + + +def test_refresh_with_user_credentials_as_source( + authorized_user_file, + impersonated_service_account_credentials, + http_request, + token_info, +): + with open(authorized_user_file, "r") as fh: + info = json.load(fh) + + source_credentials = google.oauth2.credentials.Credentials( + None, + refresh_token=info["refresh_token"], + token_uri=GOOGLE_OAUTH2_TOKEN_ENDPOINT, + client_id=info["client_id"], + client_secret=info["client_secret"], + # The source credential needs this scope for the generateAccessToken request + # The user must also have `Service Account Token Creator` on the project + # that owns the impersonated service account. + # See https://cloud.google.com/iam/docs/creating-short-lived-service-account-credentials + scopes=["https://www.googleapis.com/auth/cloud-platform"], + ) + + source_credentials.refresh(http_request) + + target_scopes = [ + "https://www.googleapis.com/auth/devstorage.read_only", + "https://www.googleapis.com/auth/analytics", + ] + target_credentials = google.auth.impersonated_credentials.Credentials( + source_credentials=source_credentials, + target_principal=impersonated_service_account_credentials.service_account_email, + target_scopes=target_scopes, + lifetime=100, + ) + + target_credentials.refresh(http_request) + assert target_credentials.token + + +def test_refresh_with_service_account_credentials_as_source( + http_request, + service_account_credentials, + impersonated_service_account_credentials, + token_info, +): + source_credentials = service_account_credentials.with_scopes(["email"]) + source_credentials.refresh(http_request) + assert source_credentials.token + + target_scopes = [ + "https://www.googleapis.com/auth/devstorage.read_only", + "https://www.googleapis.com/auth/analytics", + ] + target_credentials = google.auth.impersonated_credentials.Credentials( + source_credentials=source_credentials, + target_principal=impersonated_service_account_credentials.service_account_email, + target_scopes=target_scopes, + ) + + target_credentials.refresh(http_request) + assert target_credentials.token diff --git a/packages/google-auth/system_tests/system_tests_sync/test_mtls_http.py b/packages/google-auth/system_tests/system_tests_sync/test_mtls_http.py new file mode 100644 index 000000000000..bcf2a59da880 --- /dev/null +++ b/packages/google-auth/system_tests/system_tests_sync/test_mtls_http.py @@ -0,0 +1,124 @@ +# 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 json +import mock +import os +import time +from os import path + + +import google.auth +import google.auth.credentials +from google.auth import environment_vars +from google.auth.transport import mtls +import google.auth.transport.requests +import google.auth.transport.urllib3 + +MTLS_ENDPOINT = "https://pubsub.mtls.googleapis.com/v1/projects/{}/topics" +REGULAR_ENDPOINT = "https://pubsub.googleapis.com/v1/projects/{}/topics" + + +def test_requests(): + credentials, project_id = google.auth.default() + credentials = google.auth.credentials.with_scopes_if_required( + credentials, ["https://www.googleapis.com/auth/pubsub"] + ) + + authed_session = google.auth.transport.requests.AuthorizedSession(credentials) + with mock.patch.dict(os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"}): + authed_session.configure_mtls_channel() + + # If the devices has default client cert source, then a mutual TLS channel + # is supposed to be created. + assert authed_session.is_mtls == mtls.has_default_client_cert_source() + + # Sleep 1 second to avoid 503 error. + time.sleep(1) + + if authed_session.is_mtls: + response = authed_session.get(MTLS_ENDPOINT.format(project_id)) + else: + response = authed_session.get(REGULAR_ENDPOINT.format(project_id)) + + assert response.ok + + +def test_urllib3(): + credentials, project_id = google.auth.default() + credentials = google.auth.credentials.with_scopes_if_required( + credentials, ["https://www.googleapis.com/auth/pubsub"] + ) + + authed_http = google.auth.transport.urllib3.AuthorizedHttp(credentials) + with mock.patch.dict(os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"}): + is_mtls = authed_http.configure_mtls_channel() + + # If the devices has default client cert source, then a mutual TLS channel + # is supposed to be created. + assert is_mtls == mtls.has_default_client_cert_source() + + # Sleep 1 second to avoid 503 error. + time.sleep(1) + + if is_mtls: + response = authed_http.request("GET", MTLS_ENDPOINT.format(project_id)) + else: + response = authed_http.request("GET", REGULAR_ENDPOINT.format(project_id)) + + assert response.status == 200 + + +def test_requests_with_default_client_cert_source(): + credentials, project_id = google.auth.default() + credentials = google.auth.credentials.with_scopes_if_required( + credentials, ["https://www.googleapis.com/auth/pubsub"] + ) + + authed_session = google.auth.transport.requests.AuthorizedSession(credentials) + + if mtls.has_default_client_cert_source(): + with mock.patch.dict(os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"}): + authed_session.configure_mtls_channel( + client_cert_callback=mtls.default_client_cert_source() + ) + + assert authed_session.is_mtls + + # Sleep 1 second to avoid 503 error. + time.sleep(1) + + response = authed_session.get(MTLS_ENDPOINT.format(project_id)) + assert response.ok + + +def test_urllib3_with_default_client_cert_source(): + credentials, project_id = google.auth.default() + credentials = google.auth.credentials.with_scopes_if_required( + credentials, ["https://www.googleapis.com/auth/pubsub"] + ) + + authed_http = google.auth.transport.urllib3.AuthorizedHttp(credentials) + + if mtls.has_default_client_cert_source(): + with mock.patch.dict(os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"}): + assert authed_http.configure_mtls_channel( + client_cert_callback=mtls.default_client_cert_source() + ) + + # Sleep 1 second to avoid 503 error. + time.sleep(1) + + response = authed_http.request("GET", MTLS_ENDPOINT.format(project_id)) + assert response.status == 200 diff --git a/packages/google-auth/system_tests/system_tests_sync/test_oauth2_credentials.py b/packages/google-auth/system_tests/system_tests_sync/test_oauth2_credentials.py new file mode 100644 index 000000000000..908db3145c56 --- /dev/null +++ b/packages/google-auth/system_tests/system_tests_sync/test_oauth2_credentials.py @@ -0,0 +1,55 @@ +# Copyright 2016 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 json + +from google.auth import _helpers +import google.oauth2.credentials + +GOOGLE_OAUTH2_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token" + + +def test_refresh(authorized_user_file, http_request, token_info): + with open(authorized_user_file, "r") as fh: + info = json.load(fh) + + credentials = google.oauth2.credentials.Credentials( + None, # No access token, must be refreshed. + refresh_token=info["refresh_token"], + token_uri=GOOGLE_OAUTH2_TOKEN_ENDPOINT, + client_id=info["client_id"], + client_secret=info["client_secret"], + ) + + credentials.refresh(http_request) + + assert credentials.token + + info = token_info(credentials.token) + + info_scopes = _helpers.string_to_scopes(info["scope"]) + + # Canonical list of scopes at https://cloud.google.com/sdk/gcloud/reference/auth/application-default/login + # or do `gcloud auth application-defaut login --help` + canonical_scopes = set( + [ + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/cloud-platform", + "openid", + ] + ) + # When running the test locally, we always have an additional "accounts.reauth" scope. + canonical_scopes_with_reauth = canonical_scopes.copy() + canonical_scopes_with_reauth.add("https://www.googleapis.com/auth/accounts.reauth") + assert set(info_scopes) == canonical_scopes or set(info_scopes) == canonical_scopes_with_reauth diff --git a/packages/google-auth/system_tests/system_tests_sync/test_requests.py b/packages/google-auth/system_tests/system_tests_sync/test_requests.py new file mode 100644 index 000000000000..1b3fba7b0c80 --- /dev/null +++ b/packages/google-auth/system_tests/system_tests_sync/test_requests.py @@ -0,0 +1,42 @@ +# Copyright 2021 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 google.auth +import google.auth.credentials +import google.auth.transport.requests +from google.oauth2 import service_account + + +def test_authorized_session_with_service_account_and_self_signed_jwt(): + credentials, project_id = google.auth.default() + + credentials = credentials.with_scopes( + scopes=[], + default_scopes=["https://www.googleapis.com/auth/pubsub"], + ) + + session = google.auth.transport.requests.AuthorizedSession( + credentials=credentials, default_host="pubsub.googleapis.com" + ) + + # List Pub/Sub Topics through the REST API + # https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.topics/list + url = "https://pubsub.googleapis.com/v1/projects/{}/topics".format(project_id) + with session: + response = session.get(url) + response.raise_for_status() + + # Check that self-signed JWT was created and is being used + assert credentials._jwt_credentials is not None + assert credentials._jwt_credentials.token.decode() == credentials.token diff --git a/packages/google-auth/system_tests/system_tests_sync/test_service_account.py b/packages/google-auth/system_tests/system_tests_sync/test_service_account.py new file mode 100644 index 000000000000..498b75b2233c --- /dev/null +++ b/packages/google-auth/system_tests/system_tests_sync/test_service_account.py @@ -0,0 +1,65 @@ +# Copyright 2016 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 _helpers +from google.auth import exceptions +from google.auth import iam +from google.oauth2 import service_account + + +@pytest.fixture +def credentials(service_account_file): + yield service_account.Credentials.from_service_account_file(service_account_file) + + +def test_refresh_no_scopes(http_request, credentials): + with pytest.raises(exceptions.RefreshError): + credentials.refresh(http_request) + + +def test_refresh_success(http_request, credentials, token_info): + credentials = credentials.with_scopes(["email", "profile"]) + + credentials.refresh(http_request) + + assert credentials.token + + info = token_info(credentials.token) + + assert info["email"] == credentials.service_account_email + info_scopes = _helpers.string_to_scopes(info["scope"]) + assert set(info_scopes) == set( + [ + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + ] + ) + +def test_iam_signer(http_request, credentials): + credentials = credentials.with_scopes( + ["https://www.googleapis.com/auth/iam"] + ) + + # Verify iamcredentials signer. + signer = iam.Signer( + http_request, + credentials, + credentials.service_account_email + ) + + signed_blob = signer.sign("message") + + assert isinstance(signed_blob, bytes) diff --git a/packages/google-auth/system_tests/system_tests_sync/test_urllib3.py b/packages/google-auth/system_tests/system_tests_sync/test_urllib3.py new file mode 100644 index 000000000000..916211ac6786 --- /dev/null +++ b/packages/google-auth/system_tests/system_tests_sync/test_urllib3.py @@ -0,0 +1,44 @@ +# Copyright 2021 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 google.auth +import google.auth.credentials +import google.auth.transport.requests +from google.oauth2 import service_account + + +def test_authorized_session_with_service_account_and_self_signed_jwt(): + credentials, project_id = google.auth.default() + + credentials = credentials.with_scopes( + scopes=[], + default_scopes=["https://www.googleapis.com/auth/pubsub"], + ) + + http = google.auth.transport.urllib3.AuthorizedHttp( + credentials=credentials, default_host="pubsub.googleapis.com" + ) + + # List Pub/Sub Topics through the REST API + # https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.topics/list + response = http.urlopen( + method="GET", + url="https://pubsub.googleapis.com/v1/projects/{}/topics".format(project_id) + ) + + assert response.status == 200 + + # Check that self-signed JWT was created and is being used + assert credentials._jwt_credentials is not None + assert credentials._jwt_credentials.token.decode() == credentials.token diff --git a/packages/google-auth/testing/constraints-3.10.txt b/packages/google-auth/testing/constraints-3.10.txt new file mode 100644 index 000000000000..b9cf106f0cc2 --- /dev/null +++ b/packages/google-auth/testing/constraints-3.10.txt @@ -0,0 +1 @@ +urllib3<2.0.0 \ No newline at end of file diff --git a/packages/google-auth/testing/constraints-3.11.txt b/packages/google-auth/testing/constraints-3.11.txt new file mode 100644 index 000000000000..09aeeca1c1bc --- /dev/null +++ b/packages/google-auth/testing/constraints-3.11.txt @@ -0,0 +1 @@ +urllib3>2.0.0 \ No newline at end of file diff --git a/packages/google-auth/testing/constraints-3.12.txt b/packages/google-auth/testing/constraints-3.12.txt new file mode 100644 index 000000000000..09aeeca1c1bc --- /dev/null +++ b/packages/google-auth/testing/constraints-3.12.txt @@ -0,0 +1 @@ +urllib3>2.0.0 \ No newline at end of file diff --git a/packages/google-auth/testing/constraints-3.13.txt b/packages/google-auth/testing/constraints-3.13.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/google-auth/testing/constraints-3.7.txt b/packages/google-auth/testing/constraints-3.7.txt new file mode 100644 index 000000000000..f3fd641a9206 --- /dev/null +++ b/packages/google-auth/testing/constraints-3.7.txt @@ -0,0 +1,14 @@ +# This constraints file is used to check that lower bounds +# are correct in setup.py +# List *all* library dependencies and extras in this file. +# Pin the version to the lower bound. +# +# e.g., if setup.py has "foo >= 1.14.0, < 2.0.0dev", +# Then this file should have foo==1.14.0 +cachetools==2.0.0 +pyasn1-modules==0.2.1 +setuptools==40.3.0 +rsa==3.1.4 +aiohttp==3.6.2 +requests==2.20.0 +pyjwt==2.0 \ No newline at end of file diff --git a/packages/google-auth/testing/constraints-3.8.txt b/packages/google-auth/testing/constraints-3.8.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/google-auth/testing/constraints-3.9.txt b/packages/google-auth/testing/constraints-3.9.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/google-auth/tests/__init__.py b/packages/google-auth/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/google-auth/tests/aio/test__helpers.py b/packages/google-auth/tests/aio/test__helpers.py new file mode 100644 index 000000000000..829dc97ff33c --- /dev/null +++ b/packages/google-auth/tests/aio/test__helpers.py @@ -0,0 +1,106 @@ +# Copyright 2025 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 json +import logging + +import pytest # type: ignore + +from google.auth.aio import _helpers + +# _MOCK_BASE_LOGGER_NAME is the base logger namespace used for testing. +_MOCK_BASE_LOGGER_NAME = "foogle" + +# _MOCK_CHILD_LOGGER_NAME is the child logger namespace used for testing. +_MOCK_CHILD_LOGGER_NAME = "foogle.bar" + + +@pytest.fixture +def logger(): + """Returns a child logger for testing.""" + logger = logging.getLogger(_MOCK_CHILD_LOGGER_NAME) + logger.level = logging.NOTSET + logger.handlers = [] + logger.propagate = True + return logger + + +@pytest.fixture +def base_logger(): + """Returns a child logger for testing.""" + logger = logging.getLogger(_MOCK_BASE_LOGGER_NAME) + logger.level = logging.NOTSET + logger.handlers = [] + logger.propagate = True + return logger + + +@pytest.mark.asyncio +async def test_response_log_debug_enabled(logger, caplog, base_logger): + caplog.set_level(logging.DEBUG, logger=_MOCK_CHILD_LOGGER_NAME) + await _helpers.response_log_async(logger, {"payload": None}) + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.message == "Response received..." + assert record.httpResponse == "" + + +@pytest.mark.asyncio +async def test_response_log_debug_disabled(logger, caplog, base_logger): + caplog.set_level(logging.INFO, logger=_MOCK_CHILD_LOGGER_NAME) + await _helpers.response_log_async(logger, "another_response") + assert "Response received..." not in caplog.text + + +@pytest.mark.asyncio +async def test_response_log_debug_enabled_response_json(logger, caplog, base_logger): + response = None + caplog.set_level(logging.DEBUG, logger=_MOCK_CHILD_LOGGER_NAME) + await _helpers.response_log_async(logger, response) + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.message == "Response received..." + assert record.httpResponse == "" + + +@pytest.mark.asyncio +async def test_parse_response_async_json_valid(): + class MockResponse: + async def json(self): + return {"data": "test"} + + response = MockResponse() + expected = {"data": "test"} + assert await _helpers._parse_response_async(response) == expected + + +@pytest.mark.asyncio +async def test_parse_response_async_json_invalid(): + class MockResponse: + def json(self): + raise json.JSONDecodeError("msg", "doc", 0) + + response = MockResponse() + assert await _helpers._parse_response_async(response) is None + + +@pytest.mark.asyncio +async def test_parse_response_async_no_json_method(): + response = "plain text" + assert await _helpers._parse_response_async(response) is None + + +@pytest.mark.asyncio +async def test_parse_response_async_none(): + assert await _helpers._parse_response_async(None) is None diff --git a/packages/google-auth/tests/compute_engine/__init__.py b/packages/google-auth/tests/compute_engine/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/google-auth/tests/compute_engine/data/smbios_product_name b/packages/google-auth/tests/compute_engine/data/smbios_product_name new file mode 100644 index 000000000000..2ca735d9b34e --- /dev/null +++ b/packages/google-auth/tests/compute_engine/data/smbios_product_name @@ -0,0 +1 @@ +Google Compute Engine diff --git a/packages/google-auth/tests/compute_engine/data/smbios_product_name_non_google b/packages/google-auth/tests/compute_engine/data/smbios_product_name_non_google new file mode 100644 index 000000000000..9fd177038edd --- /dev/null +++ b/packages/google-auth/tests/compute_engine/data/smbios_product_name_non_google @@ -0,0 +1 @@ +ABC Compute Engine diff --git a/packages/google-auth/tests/compute_engine/test__metadata.py b/packages/google-auth/tests/compute_engine/test__metadata.py new file mode 100644 index 000000000000..adb63f667a75 --- /dev/null +++ b/packages/google-auth/tests/compute_engine/test__metadata.py @@ -0,0 +1,823 @@ +# Copyright 2016 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 datetime +import http.client as http_client +import importlib +import json +import os + +import mock +import pytest # type: ignore +import requests + +from google.auth import _helpers +from google.auth import environment_vars +from google.auth import exceptions +from google.auth import transport +from google.auth.compute_engine import _metadata +from google.auth.transport import requests as google_auth_requests + +PATH = "instance/service-accounts/default" + +DATA_DIR = os.path.join(os.path.dirname(__file__), "data") +SMBIOS_PRODUCT_NAME_FILE = os.path.join(DATA_DIR, "smbios_product_name") +SMBIOS_PRODUCT_NAME_NONEXISTENT_FILE = os.path.join( + DATA_DIR, "smbios_product_name_nonexistent" +) +SMBIOS_PRODUCT_NAME_NON_GOOGLE = os.path.join( + DATA_DIR, "smbios_product_name_non_google" +) + +ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE = ( + "gl-python/3.7 auth/1.1 auth-request-type/at cred-type/mds" +) +MDS_PING_METRICS_HEADER_VALUE = "gl-python/3.7 auth/1.1 auth-request-type/mds" +MDS_PING_REQUEST_HEADER = { + "metadata-flavor": "Google", + "x-goog-api-client": MDS_PING_METRICS_HEADER_VALUE, +} + + +def make_request(data, status=http_client.OK, headers=None, retry=False): + response = mock.create_autospec(transport.Response, instance=True) + response.status = status + response.data = _helpers.to_bytes(data) + response.headers = headers or {} + + request = mock.create_autospec(transport.Request) + if retry: + request.side_effect = [exceptions.TransportError(), response] + else: + request.return_value = response + + return request + + +def test_detect_gce_residency_linux_success(): + _metadata._GCE_PRODUCT_NAME_FILE = SMBIOS_PRODUCT_NAME_FILE + assert _metadata.detect_gce_residency_linux() + + +def test_detect_gce_residency_linux_non_google(): + _metadata._GCE_PRODUCT_NAME_FILE = SMBIOS_PRODUCT_NAME_NON_GOOGLE + assert not _metadata.detect_gce_residency_linux() + + +def test_detect_gce_residency_linux_nonexistent(): + _metadata._GCE_PRODUCT_NAME_FILE = SMBIOS_PRODUCT_NAME_NONEXISTENT_FILE + assert not _metadata.detect_gce_residency_linux() + + +def test_is_on_gce_ping_success(): + request = make_request("", headers=_metadata._METADATA_HEADERS) + assert _metadata.is_on_gce(request) + + +@mock.patch("os.name", new="nt") +def test_is_on_gce_windows_success(): + request = make_request("", headers={_metadata._METADATA_FLAVOR_HEADER: "meep"}) + assert not _metadata.is_on_gce(request) + + +@mock.patch("os.name", new="posix") +def test_is_on_gce_linux_success(): + request = make_request("", headers={_metadata._METADATA_FLAVOR_HEADER: "meep"}) + _metadata._GCE_PRODUCT_NAME_FILE = SMBIOS_PRODUCT_NAME_FILE + assert _metadata.is_on_gce(request) + + +@mock.patch("google.auth.metrics.mds_ping", return_value=MDS_PING_METRICS_HEADER_VALUE) +def test_ping_success(mock_metrics_header_value): + request = make_request("", headers=_metadata._METADATA_HEADERS) + + assert _metadata.ping(request) + + request.assert_called_once_with( + method="GET", + url="http://169.254.169.254", + headers=MDS_PING_REQUEST_HEADER, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + + +@mock.patch("google.auth.metrics.mds_ping", return_value=MDS_PING_METRICS_HEADER_VALUE) +def test_ping_success_retry(mock_metrics_header_value): + request = make_request("", headers=_metadata._METADATA_HEADERS, retry=True) + + assert _metadata.ping(request) + + request.assert_called_with( + method="GET", + url="http://169.254.169.254", + headers=MDS_PING_REQUEST_HEADER, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + assert request.call_count == 2 + + +@mock.patch("time.sleep", return_value=None) +def test_ping_failure_bad_flavor(mock_sleep): + request = make_request("", headers={_metadata._METADATA_FLAVOR_HEADER: "meep"}) + + assert not _metadata.ping(request) + + +@mock.patch("time.sleep", return_value=None) +def test_ping_failure_connection_failed(mock_sleep): + request = make_request("") + request.side_effect = exceptions.TransportError() + + assert not _metadata.ping(request) + + +@mock.patch("google.auth.metrics.mds_ping", return_value=MDS_PING_METRICS_HEADER_VALUE) +def test_ping_success_custom_root(mock_metrics_header_value): + request = make_request("", headers=_metadata._METADATA_HEADERS) + + fake_ip = "1.2.3.4" + os.environ[environment_vars.GCE_METADATA_IP] = fake_ip + importlib.reload(_metadata) + + try: + assert _metadata.ping(request) + finally: + del os.environ[environment_vars.GCE_METADATA_IP] + importlib.reload(_metadata) + + request.assert_called_once_with( + method="GET", + url="http://" + fake_ip, + headers=MDS_PING_REQUEST_HEADER, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + + +def test_get_success_json(): + key, value = "foo", "bar" + + data = json.dumps({key: value}) + request = make_request(data, headers={"content-type": "application/json"}) + + result = _metadata.get(request, PATH) + + request.assert_called_once_with( + method="GET", + url="http://metadata.google.internal/computeMetadata/v1/" + PATH, + headers=_metadata._METADATA_HEADERS, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + assert result[key] == value + + +def test_get_success_json_content_type_charset(): + key, value = "foo", "bar" + + data = json.dumps({key: value}) + request = make_request( + data, headers={"content-type": "application/json; charset=UTF-8"} + ) + + result = _metadata.get(request, PATH) + + request.assert_called_once_with( + method="GET", + url="http://metadata.google.internal/computeMetadata/v1/" + PATH, + headers=_metadata._METADATA_HEADERS, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + assert result[key] == value + + +@mock.patch("time.sleep", return_value=None) +def test_get_success_retry(mock_sleep): + key, value = "foo", "bar" + + data = json.dumps({key: value}) + request = make_request( + data, headers={"content-type": "application/json"}, retry=True + ) + + result = _metadata.get(request, PATH) + + request.assert_called_with( + method="GET", + url="http://metadata.google.internal/computeMetadata/v1/" + PATH, + headers=_metadata._METADATA_HEADERS, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + assert request.call_count == 2 + assert result[key] == value + + +def test_get_success_text(): + data = "foobar" + request = make_request(data, headers={"content-type": "text/plain"}) + + result = _metadata.get(request, PATH) + + request.assert_called_once_with( + method="GET", + url="http://metadata.google.internal/computeMetadata/v1/" + PATH, + headers=_metadata._METADATA_HEADERS, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + assert result == data + + +def test_get_success_params(): + data = "foobar" + request = make_request(data, headers={"content-type": "text/plain"}) + params = {"recursive": "true"} + + result = _metadata.get(request, PATH, params=params) + + request.assert_called_once_with( + method="GET", + url="http://metadata.google.internal/computeMetadata/v1/" + + PATH + + "?recursive=true", + headers=_metadata._METADATA_HEADERS, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + assert result == data + + +def test_get_success_recursive_and_params(): + data = "foobar" + request = make_request(data, headers={"content-type": "text/plain"}) + params = {"recursive": "false"} + result = _metadata.get(request, PATH, recursive=True, params=params) + + request.assert_called_once_with( + method="GET", + url="http://metadata.google.internal/computeMetadata/v1/" + + PATH + + "?recursive=true", + headers=_metadata._METADATA_HEADERS, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + assert result == data + + +def test_get_success_recursive(): + data = "foobar" + request = make_request(data, headers={"content-type": "text/plain"}) + + result = _metadata.get(request, PATH, recursive=True) + + request.assert_called_once_with( + method="GET", + url="http://metadata.google.internal/computeMetadata/v1/" + + PATH + + "?recursive=true", + headers=_metadata._METADATA_HEADERS, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + assert result == data + + +def test_get_success_custom_root_new_variable(): + request = make_request("{}", headers={"content-type": "application/json"}) + + fake_root = "another.metadata.service" + os.environ[environment_vars.GCE_METADATA_HOST] = fake_root + importlib.reload(_metadata) + + try: + _metadata.get(request, PATH) + finally: + del os.environ[environment_vars.GCE_METADATA_HOST] + importlib.reload(_metadata) + + request.assert_called_once_with( + method="GET", + url="http://{}/computeMetadata/v1/{}".format(fake_root, PATH), + headers=_metadata._METADATA_HEADERS, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + + +def test_get_success_custom_root_old_variable(): + request = make_request("{}", headers={"content-type": "application/json"}) + + fake_root = "another.metadata.service" + os.environ[environment_vars.GCE_METADATA_ROOT] = fake_root + importlib.reload(_metadata) + + try: + _metadata.get(request, PATH) + finally: + del os.environ[environment_vars.GCE_METADATA_ROOT] + importlib.reload(_metadata) + + request.assert_called_once_with( + method="GET", + url="http://{}/computeMetadata/v1/{}".format(fake_root, PATH), + headers=_metadata._METADATA_HEADERS, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + + +def test_get_success_custom_root(): + request = make_request("{}", headers={"content-type": "application/json"}) + + fake_root = "http://another.metadata.service" + + _metadata.get(request, PATH, root=fake_root) + + request.assert_called_once_with( + method="GET", + url="{}/{}".format(fake_root, PATH), + headers=_metadata._METADATA_HEADERS, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + + +@mock.patch("time.sleep", return_value=None) +def test_get_failure(mock_sleep): + request = make_request("Metadata error", status=http_client.NOT_FOUND) + + with pytest.raises(exceptions.TransportError) as excinfo: + _metadata.get(request, PATH) + + assert excinfo.match(r"Metadata error") + + request.assert_called_once_with( + method="GET", + url="http://metadata.google.internal/computeMetadata/v1/" + PATH, + headers=_metadata._METADATA_HEADERS, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + + +def test_get_return_none_for_not_found_error(): + request = make_request("Metadata error", status=http_client.NOT_FOUND) + + assert _metadata.get(request, PATH, return_none_for_not_found_error=True) is None + + request.assert_called_once_with( + method="GET", + url="http://metadata.google.internal/computeMetadata/v1/" + PATH, + headers=_metadata._METADATA_HEADERS, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + + +@mock.patch("time.sleep", return_value=None) +def test_get_failure_connection_failed(mock_sleep): + request = make_request("") + request.side_effect = exceptions.TransportError("failure message") + + with pytest.raises(exceptions.TransportError) as excinfo: + _metadata.get(request, PATH) + + assert excinfo.match( + r"Compute Engine Metadata server unavailable due to failure message" + ) + + request.assert_called_with( + method="GET", + url="http://metadata.google.internal/computeMetadata/v1/" + PATH, + headers=_metadata._METADATA_HEADERS, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + assert request.call_count == 5 + + +def test_get_too_many_requests_retryable_error_failure(): + request = make_request("too many requests", status=http_client.TOO_MANY_REQUESTS) + + with pytest.raises(exceptions.TransportError) as excinfo: + _metadata.get(request, PATH) + + assert excinfo.match( + r"Compute Engine Metadata server unavailable due to too many requests" + ) + + request.assert_called_with( + method="GET", + url="http://metadata.google.internal/computeMetadata/v1/" + PATH, + headers=_metadata._METADATA_HEADERS, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + assert request.call_count == 5 + + +def test_get_failure_bad_json(): + request = make_request("{", headers={"content-type": "application/json"}) + + with pytest.raises(exceptions.TransportError) as excinfo: + _metadata.get(request, PATH) + + assert excinfo.match(r"invalid JSON") + + request.assert_called_once_with( + method="GET", + url="http://metadata.google.internal/computeMetadata/v1/" + PATH, + headers=_metadata._METADATA_HEADERS, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + + +def test_get_project_id(): + project = "example-project" + request = make_request(project, headers={"content-type": "text/plain"}) + + project_id = _metadata.get_project_id(request) + + request.assert_called_once_with( + method="GET", + url="http://metadata.google.internal/computeMetadata/v1/project/project-id", + headers=_metadata._METADATA_HEADERS, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + assert project_id == project + + +def test_get_universe_domain_success(): + request = make_request( + "fake_universe_domain", headers={"content-type": "text/plain"} + ) + + universe_domain = _metadata.get_universe_domain(request) + + request.assert_called_once_with( + method="GET", + url="http://metadata.google.internal/computeMetadata/v1/universe/universe-domain", + headers=_metadata._METADATA_HEADERS, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + assert universe_domain == "fake_universe_domain" + + +def test_get_universe_domain_success_empty_response(): + request = make_request("", headers={"content-type": "text/plain"}) + + universe_domain = _metadata.get_universe_domain(request) + + request.assert_called_once_with( + method="GET", + url="http://metadata.google.internal/computeMetadata/v1/universe/universe-domain", + headers=_metadata._METADATA_HEADERS, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + assert universe_domain == "googleapis.com" + + +def test_get_universe_domain_not_found(): + # Test that if the universe domain endpoint returns 404 error, we should + # use googleapis.com as the universe domain + request = make_request("not found", status=http_client.NOT_FOUND) + + universe_domain = _metadata.get_universe_domain(request) + + request.assert_called_once_with( + method="GET", + url="http://metadata.google.internal/computeMetadata/v1/universe/universe-domain", + headers=_metadata._METADATA_HEADERS, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + assert universe_domain == "googleapis.com" + + +def test_get_universe_domain_retryable_error_failure(): + # Test that if the universe domain endpoint returns a retryable error + # we should retry. + # + # In this case, the error persists, and we still fail after retrying. + request = make_request("too many requests", status=http_client.TOO_MANY_REQUESTS) + + with pytest.raises(exceptions.TransportError) as excinfo: + _metadata.get_universe_domain(request) + + assert excinfo.match(r"Compute Engine Metadata server unavailable") + + request.assert_called_with( + method="GET", + url="http://metadata.google.internal/computeMetadata/v1/universe/universe-domain", + headers=_metadata._METADATA_HEADERS, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + assert request.call_count == 5 + + +def test_get_universe_domain_retryable_error_success(): + # Test that if the universe domain endpoint returns a retryable error + # we should retry. + # + # In this case, the error is temporary, and we succeed after retrying. + request_error = make_request( + "too many requests", status=http_client.TOO_MANY_REQUESTS + ) + request_ok = make_request( + "fake_universe_domain", headers={"content-type": "text/plain"} + ) + + class _RequestErrorOnce: + """This class forwards the request parameters to `request_error` once. + + All subsequent calls are forwarded to `request_ok`. + """ + + def __init__(self, request_error, request_ok): + self._request_error = request_error + self._request_ok = request_ok + self._call_index = 0 + + def request(self, *args, **kwargs): + if self._call_index == 0: + self._call_index += 1 + return self._request_error(*args, **kwargs) + + return self._request_ok(*args, **kwargs) + + request = _RequestErrorOnce(request_error, request_ok).request + + universe_domain = _metadata.get_universe_domain(request) + + request_error.assert_called_once_with( + method="GET", + url="http://metadata.google.internal/computeMetadata/v1/universe/universe-domain", + headers=_metadata._METADATA_HEADERS, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + request_ok.assert_called_once_with( + method="GET", + url="http://metadata.google.internal/computeMetadata/v1/universe/universe-domain", + headers=_metadata._METADATA_HEADERS, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + + assert universe_domain == "fake_universe_domain" + + +def test_get_universe_domain_other_error(): + # Test that if the universe domain endpoint returns an error other than 404 + # we should throw the error + request = make_request("unauthorized", status=http_client.UNAUTHORIZED) + + with pytest.raises(exceptions.TransportError) as excinfo: + _metadata.get_universe_domain(request) + + assert excinfo.match(r"unauthorized") + + request.assert_called_once_with( + method="GET", + url="http://metadata.google.internal/computeMetadata/v1/universe/universe-domain", + headers=_metadata._METADATA_HEADERS, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + + +@mock.patch( + "google.auth.metrics.token_request_access_token_mds", + return_value=ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, +) +@mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) +def test_get_service_account_token(utcnow, mock_metrics_header_value): + ttl = 500 + request = make_request( + json.dumps({"access_token": "token", "expires_in": ttl}), + headers={"content-type": "application/json"}, + ) + + token, expiry = _metadata.get_service_account_token(request) + + request.assert_called_once_with( + method="GET", + url="http://metadata.google.internal/computeMetadata/v1/" + PATH + "/token", + headers={ + "metadata-flavor": "Google", + "x-goog-api-client": ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, + }, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + assert token == "token" + assert expiry == utcnow() + datetime.timedelta(seconds=ttl) + + +@mock.patch( + "google.auth.metrics.token_request_access_token_mds", + return_value=ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, +) +@mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) +def test_get_service_account_token_with_scopes_list(utcnow, mock_metrics_header_value): + ttl = 500 + request = make_request( + json.dumps({"access_token": "token", "expires_in": ttl}), + headers={"content-type": "application/json"}, + ) + + token, expiry = _metadata.get_service_account_token(request, scopes=["foo", "bar"]) + + request.assert_called_once_with( + method="GET", + url="http://metadata.google.internal/computeMetadata/v1/" + + PATH + + "/token" + + "?scopes=foo%2Cbar", + headers={ + "metadata-flavor": "Google", + "x-goog-api-client": ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, + }, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + assert token == "token" + assert expiry == utcnow() + datetime.timedelta(seconds=ttl) + + +@mock.patch( + "google.auth.metrics.token_request_access_token_mds", + return_value=ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, +) +@mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) +def test_get_service_account_token_with_scopes_string( + utcnow, mock_metrics_header_value +): + ttl = 500 + request = make_request( + json.dumps({"access_token": "token", "expires_in": ttl}), + headers={"content-type": "application/json"}, + ) + + token, expiry = _metadata.get_service_account_token(request, scopes="foo,bar") + + request.assert_called_once_with( + method="GET", + url="http://metadata.google.internal/computeMetadata/v1/" + + PATH + + "/token" + + "?scopes=foo%2Cbar", + headers={ + "metadata-flavor": "Google", + "x-goog-api-client": ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, + }, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + assert token == "token" + assert expiry == utcnow() + datetime.timedelta(seconds=ttl) + + +def test_get_service_account_info(): + key, value = "foo", "bar" + request = make_request( + json.dumps({key: value}), headers={"content-type": "application/json"} + ) + + info = _metadata.get_service_account_info(request) + + request.assert_called_once_with( + method="GET", + url="http://metadata.google.internal/computeMetadata/v1/" + + PATH + + "/?recursive=true", + headers=_metadata._METADATA_HEADERS, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + + assert info[key] == value + + +def test__get_metadata_root_mtls(): + assert ( + _metadata._get_metadata_root(use_mtls=True) + == "https://metadata.google.internal/computeMetadata/v1/" + ) + + +def test__get_metadata_root_no_mtls(): + assert ( + _metadata._get_metadata_root(use_mtls=False) + == "http://metadata.google.internal/computeMetadata/v1/" + ) + + +def test__get_metadata_ip_root_mtls(): + assert _metadata._get_metadata_ip_root(use_mtls=True) == "https://169.254.169.254" + + +def test__get_metadata_ip_root_no_mtls(): + assert _metadata._get_metadata_ip_root(use_mtls=False) == "http://169.254.169.254" + + +@mock.patch("google.auth.compute_engine._mtls.MdsMtlsAdapter") +def test__prepare_request_for_mds_mtls(mock_mds_mtls_adapter): + request = google_auth_requests.Request(mock.create_autospec(requests.Session)) + _metadata._prepare_request_for_mds(request, use_mtls=True) + mock_mds_mtls_adapter.assert_called_once() + assert request.session.mount.call_count == len(_metadata._GCE_DEFAULT_MDS_HOSTS) + + +def test__prepare_request_for_mds_no_mtls(): + request = mock.Mock() + _metadata._prepare_request_for_mds(request, use_mtls=False) + request.session.mount.assert_not_called() + + +@mock.patch("google.auth.metrics.mds_ping", return_value=MDS_PING_METRICS_HEADER_VALUE) +@mock.patch("google.auth.compute_engine._mtls.MdsMtlsAdapter") +@mock.patch("google.auth.compute_engine._mtls.should_use_mds_mtls", return_value=True) +@mock.patch("google.auth.transport.requests.Request") +def test_ping_mtls( + mock_request, mock_should_use_mtls, mock_mds_mtls_adapter, mock_metrics_header_value +): + response = mock.create_autospec(transport.Response, instance=True) + response.status = http_client.OK + response.headers = _metadata._METADATA_HEADERS + mock_request.return_value = response + + assert _metadata.ping(mock_request) + + mock_should_use_mtls.assert_called_once() + mock_mds_mtls_adapter.assert_called_once() + mock_request.assert_called_once_with( + url="https://169.254.169.254", + method="GET", + headers=MDS_PING_REQUEST_HEADER, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + + +@mock.patch("google.auth.compute_engine._mtls.MdsMtlsAdapter") +@mock.patch("google.auth.compute_engine._mtls.should_use_mds_mtls", return_value=True) +@mock.patch("google.auth.transport.requests.Request") +def test_get_mtls(mock_request, mock_should_use_mtls, mock_mds_mtls_adapter): + response = mock.create_autospec(transport.Response, instance=True) + response.status = http_client.OK + response.data = _helpers.to_bytes("{}") + response.headers = {"content-type": "application/json"} + mock_request.return_value = response + + _metadata.get(mock_request, "some/path") + + mock_should_use_mtls.assert_called_once() + mock_mds_mtls_adapter.assert_called_once() + mock_request.assert_called_once_with( + url="https://metadata.google.internal/computeMetadata/v1/some/path", + method="GET", + headers=_metadata._METADATA_HEADERS, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + + +@pytest.mark.parametrize( + "mds_mode, metadata_host, expect_exception", + [ + (_metadata._mtls.MdsMtlsMode.STRICT, _metadata._GCE_DEFAULT_HOST, False), + (_metadata._mtls.MdsMtlsMode.STRICT, _metadata._GCE_DEFAULT_MDS_IP, False), + (_metadata._mtls.MdsMtlsMode.STRICT, "custom.host", True), + (_metadata._mtls.MdsMtlsMode.NONE, "custom.host", False), + (_metadata._mtls.MdsMtlsMode.DEFAULT, _metadata._GCE_DEFAULT_HOST, False), + (_metadata._mtls.MdsMtlsMode.DEFAULT, _metadata._GCE_DEFAULT_MDS_IP, False), + ], +) +@mock.patch("google.auth.compute_engine._mtls._parse_mds_mode") +def test_validate_gce_mds_configured_environment( + mock_parse_mds_mode, mds_mode, metadata_host, expect_exception +): + mock_parse_mds_mode.return_value = mds_mode + with mock.patch( + "google.auth.compute_engine._metadata._GCE_METADATA_HOST", new=metadata_host + ): + if expect_exception: + with pytest.raises(exceptions.MutualTLSChannelError): + _metadata._validate_gce_mds_configured_environment() + else: + _metadata._validate_gce_mds_configured_environment() + mock_parse_mds_mode.assert_called_once() + + +@mock.patch("google.auth.compute_engine._mtls.MdsMtlsAdapter") +def test__prepare_request_for_mds_mtls_session_exists(mock_mds_mtls_adapter): + mock_session = mock.create_autospec(requests.Session) + request = google_auth_requests.Request(mock_session) + _metadata._prepare_request_for_mds(request, use_mtls=True) + + mock_mds_mtls_adapter.assert_called_once() + assert mock_session.mount.call_count == len(_metadata._GCE_DEFAULT_MDS_HOSTS) + + +@mock.patch("google.auth.compute_engine._mtls.MdsMtlsAdapter") +def test__prepare_request_for_mds_mtls_no_session(mock_mds_mtls_adapter): + request = google_auth_requests.Request(None) + # Explicitly set session to None to avoid a session being created in the Request constructor. + request.session = None + + with mock.patch("requests.Session") as mock_session_class: + _metadata._prepare_request_for_mds(request, use_mtls=True) + + mock_session_class.assert_called_once() + mock_mds_mtls_adapter.assert_called_once() + assert request.session.mount.call_count == len(_metadata._GCE_DEFAULT_MDS_HOSTS) diff --git a/packages/google-auth/tests/compute_engine/test__mtls.py b/packages/google-auth/tests/compute_engine/test__mtls.py new file mode 100644 index 000000000000..fdd61a07d03d --- /dev/null +++ b/packages/google-auth/tests/compute_engine/test__mtls.py @@ -0,0 +1,288 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2024 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. +# + +from pathlib import Path + +import mock +import pytest # type: ignore +import requests + +from google.auth import environment_vars, exceptions +from google.auth.compute_engine import _mtls + + +@pytest.fixture +def mock_mds_mtls_config(): + return _mtls.MdsMtlsConfig( + ca_cert_path=Path("/fake/ca.crt"), + client_combined_cert_path=Path("/fake/client.key"), + ) + + +@mock.patch("os.name", "nt") +def test__MdsMtlsConfig_windows_defaults(): + config = _mtls.MdsMtlsConfig() + assert ( + str(config.ca_cert_path) + == "C:/ProgramData/Google/ComputeEngine/mds-mtls-root.crt" + ) + assert ( + str(config.client_combined_cert_path) + == "C:/ProgramData/Google/ComputeEngine/mds-mtls-client.key" + ) + + +@mock.patch("os.name", "posix") +def test__MdsMtlsConfig_non_windows_defaults(): + config = _mtls.MdsMtlsConfig() + assert str(config.ca_cert_path) == "/run/google-mds-mtls/root.crt" + assert str(config.client_combined_cert_path) == "/run/google-mds-mtls/client.key" + + +def test__parse_mds_mode_default(monkeypatch): + monkeypatch.delenv(environment_vars.GCE_METADATA_MTLS_MODE, raising=False) + assert _mtls._parse_mds_mode() == _mtls.MdsMtlsMode.DEFAULT + + +@pytest.mark.parametrize( + "mode_str, expected_mode", + [ + ("strict", _mtls.MdsMtlsMode.STRICT), + ("none", _mtls.MdsMtlsMode.NONE), + ("default", _mtls.MdsMtlsMode.DEFAULT), + ("STRICT", _mtls.MdsMtlsMode.STRICT), + ], +) +def test__parse_mds_mode_valid(monkeypatch, mode_str, expected_mode): + monkeypatch.setenv(environment_vars.GCE_METADATA_MTLS_MODE, mode_str) + assert _mtls._parse_mds_mode() == expected_mode + + +def test__parse_mds_mode_invalid(monkeypatch): + monkeypatch.setenv(environment_vars.GCE_METADATA_MTLS_MODE, "invalid_mode") + with pytest.raises(ValueError): + _mtls._parse_mds_mode() + + +@mock.patch("os.path.exists") +def test__certs_exist_true(mock_exists, mock_mds_mtls_config): + mock_exists.return_value = True + assert _mtls._certs_exist(mock_mds_mtls_config) is True + + +@mock.patch("os.path.exists") +def test__certs_exist_false(mock_exists, mock_mds_mtls_config): + mock_exists.return_value = False + assert _mtls._certs_exist(mock_mds_mtls_config) is False + + +@pytest.mark.parametrize( + "mtls_mode, certs_exist, expected_result", + [ + ("strict", True, True), + ("strict", False, exceptions.MutualTLSChannelError), + ("none", True, False), + ("none", False, False), + ("default", True, True), + ("default", False, False), + ], +) +@mock.patch("os.path.exists") +def test_should_use_mds_mtls( + mock_exists, monkeypatch, mtls_mode, certs_exist, expected_result +): + monkeypatch.setenv(environment_vars.GCE_METADATA_MTLS_MODE, mtls_mode) + mock_exists.return_value = certs_exist + + if isinstance(expected_result, type) and issubclass(expected_result, Exception): + with pytest.raises(expected_result): + _mtls.should_use_mds_mtls() + else: + assert _mtls.should_use_mds_mtls() is expected_result + + +@mock.patch("ssl.create_default_context") +def test_mds_mtls_adapter_init(mock_ssl_context, mock_mds_mtls_config): + adapter = _mtls.MdsMtlsAdapter(mock_mds_mtls_config) + mock_ssl_context.assert_called_once() + adapter.ssl_context.load_verify_locations.assert_called_once_with( + cafile=mock_mds_mtls_config.ca_cert_path + ) + adapter.ssl_context.load_cert_chain.assert_called_once_with( + certfile=mock_mds_mtls_config.client_combined_cert_path + ) + + +@mock.patch("ssl.create_default_context") +@mock.patch("requests.adapters.HTTPAdapter.init_poolmanager") +def test_mds_mtls_adapter_init_poolmanager( + mock_init_poolmanager, mock_ssl_context, mock_mds_mtls_config +): + adapter = _mtls.MdsMtlsAdapter(mock_mds_mtls_config) + mock_init_poolmanager.assert_called_with( + 10, 10, block=False, ssl_context=adapter.ssl_context + ) + + +@mock.patch("ssl.create_default_context") +@mock.patch("requests.adapters.HTTPAdapter.proxy_manager_for") +def test_mds_mtls_adapter_proxy_manager_for( + mock_proxy_manager_for, mock_ssl_context, mock_mds_mtls_config +): + adapter = _mtls.MdsMtlsAdapter(mock_mds_mtls_config) + adapter.proxy_manager_for("test_proxy") + mock_proxy_manager_for.assert_called_once_with( + "test_proxy", ssl_context=adapter.ssl_context + ) + + +@mock.patch("requests.adapters.HTTPAdapter.send") # Patch the PARENT class method +@mock.patch("ssl.create_default_context") +def test_mds_mtls_adapter_session_request( + mock_ssl_context, mock_super_send, mock_mds_mtls_config +): + adapter = _mtls.MdsMtlsAdapter(mock_mds_mtls_config) + session = requests.Session() + session.mount("https://", adapter) + + # Setup the parent class send return value + response = requests.Response() + response.status_code = 200 + mock_super_send.return_value = response + + response = session.get("https://fake-mds.com") + + # Assert that the request was successful + assert response.status_code == 200 + mock_super_send.assert_called_once() + + +@mock.patch("requests.adapters.HTTPAdapter.send") +@mock.patch("google.auth.compute_engine._mtls._parse_mds_mode") +@mock.patch("ssl.create_default_context") +def test_mds_mtls_adapter_send_success( + mock_ssl_context, mock_parse_mds_mode, mock_super_send, mock_mds_mtls_config +): + """Test the explicit 'happy path' where mTLS succeeds without error.""" + mock_parse_mds_mode.return_value = _mtls.MdsMtlsMode.DEFAULT + adapter = _mtls.MdsMtlsAdapter(mock_mds_mtls_config) + + # Setup the parent class send return value to be successful (200 OK) + mock_response = requests.Response() + mock_response.status_code = 200 + mock_super_send.return_value = mock_response + + request = requests.Request(method="GET", url="https://fake-mds.com").prepare() + + # Call send directly + response = adapter.send(request) + + # Verify we got the response back and no fallback happened + assert response == mock_response + mock_super_send.assert_called_once() + + +@mock.patch("google.auth.compute_engine._mtls.HTTPAdapter") +@mock.patch("google.auth.compute_engine._mtls._parse_mds_mode") +@mock.patch("ssl.create_default_context") +def test_mds_mtls_adapter_send_fallback_default_mode( + mock_ssl_context, mock_parse_mds_mode, mock_http_adapter_class, mock_mds_mtls_config +): + mock_parse_mds_mode.return_value = _mtls.MdsMtlsMode.DEFAULT + adapter = _mtls.MdsMtlsAdapter(mock_mds_mtls_config) + + mock_fallback_send = mock.Mock() + mock_http_adapter_class.return_value.send = mock_fallback_send + + # Simulate SSLError on the super().send() call + with mock.patch( + "requests.adapters.HTTPAdapter.send", side_effect=requests.exceptions.SSLError + ): + request = requests.Request(method="GET", url="https://fake-mds.com").prepare() + adapter.send(request) + + # Check that fallback to HTTPAdapter.send occurred + mock_http_adapter_class.assert_called_once() + mock_fallback_send.assert_called_once() + fallback_request = mock_fallback_send.call_args[0][0] + assert fallback_request.url == "http://fake-mds.com/" + + +@mock.patch("google.auth.compute_engine._mtls.HTTPAdapter") +@mock.patch("google.auth.compute_engine._mtls._parse_mds_mode") +@mock.patch("ssl.create_default_context") +def test_mds_mtls_adapter_send_fallback_http_error( + mock_ssl_context, mock_parse_mds_mode, mock_http_adapter_class, mock_mds_mtls_config +): + mock_parse_mds_mode.return_value = _mtls.MdsMtlsMode.DEFAULT + adapter = _mtls.MdsMtlsAdapter(mock_mds_mtls_config) + + mock_fallback_send = mock.Mock() + mock_http_adapter_class.return_value.send = mock_fallback_send + + # Simulate HTTPError on the super().send() call + mock_mtls_response = requests.Response() + mock_mtls_response.status_code = 404 + with mock.patch( + "requests.adapters.HTTPAdapter.send", return_value=mock_mtls_response + ): + request = requests.Request(method="GET", url="https://fake-mds.com").prepare() + adapter.send(request) + + # Check that fallback to HTTPAdapter.send occurred + mock_http_adapter_class.assert_called_once() + mock_fallback_send.assert_called_once() + fallback_request = mock_fallback_send.call_args[0][0] + assert fallback_request.url == "http://fake-mds.com/" + + +@mock.patch("requests.adapters.HTTPAdapter.send") +@mock.patch("google.auth.compute_engine._mtls._parse_mds_mode") +@mock.patch("ssl.create_default_context") +def test_mds_mtls_adapter_send_no_fallback_other_exception( + mock_ssl_context, mock_parse_mds_mode, mock_http_adapter_send, mock_mds_mtls_config +): + mock_parse_mds_mode.return_value = _mtls.MdsMtlsMode.DEFAULT + adapter = _mtls.MdsMtlsAdapter(mock_mds_mtls_config) + + # Simulate HTTP exception + with mock.patch( + "requests.adapters.HTTPAdapter.send", + side_effect=requests.exceptions.ConnectionError, + ): + request = requests.Request(method="GET", url="https://fake-mds.com").prepare() + with pytest.raises(requests.exceptions.ConnectionError): + adapter.send(request) + + mock_http_adapter_send.assert_not_called() + + +@mock.patch("google.auth.compute_engine._mtls._parse_mds_mode") +@mock.patch("ssl.create_default_context") +def test_mds_mtls_adapter_send_no_fallback_strict_mode( + mock_ssl_context, mock_parse_mds_mode, mock_mds_mtls_config +): + mock_parse_mds_mode.return_value = _mtls.MdsMtlsMode.STRICT + adapter = _mtls.MdsMtlsAdapter(mock_mds_mtls_config) + + # Simulate SSLError on the super().send() call + with mock.patch( + "requests.adapters.HTTPAdapter.send", side_effect=requests.exceptions.SSLError + ): + request = requests.Request(method="GET", url="https://fake-mds.com").prepare() + with pytest.raises(requests.exceptions.SSLError): + adapter.send(request) diff --git a/packages/google-auth/tests/compute_engine/test_credentials.py b/packages/google-auth/tests/compute_engine/test_credentials.py new file mode 100644 index 000000000000..1c77069938d6 --- /dev/null +++ b/packages/google-auth/tests/compute_engine/test_credentials.py @@ -0,0 +1,1345 @@ +# Copyright 2016 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 base64 +import datetime +import os + +import mock +import pytest # type: ignore +import responses # type: ignore + +from google.auth import _helpers +from google.auth import environment_vars +from google.auth import exceptions +from google.auth import jwt +from google.auth import transport +from google.auth.compute_engine import credentials +from google.auth.transport import requests +from google.oauth2 import _client + +SAMPLE_ID_TOKEN_EXP = 1584393400 + +# header: {"alg": "RS256", "typ": "JWT", "kid": "1"} +# payload: {"iss": "issuer", "iat": 1584393348, "sub": "subject", +# "exp": 1584393400,"aud": "audience"} +SAMPLE_ID_TOKEN = ( + b"eyJhbGciOiAiUlMyNTYiLCAidHlwIjogIkpXVCIsICJraWQiOiAiMSJ9." + b"eyJpc3MiOiAiaXNzdWVyIiwgImlhdCI6IDE1ODQzOTMzNDgsICJzdWIiO" + b"iAic3ViamVjdCIsICJleHAiOiAxNTg0MzkzNDAwLCAiYXVkIjogImF1ZG" + b"llbmNlIn0." + b"OquNjHKhTmlgCk361omRo18F_uY-7y0f_AmLbzW062Q1Zr61HAwHYP5FM" + b"316CK4_0cH8MUNGASsvZc3VqXAqub6PUTfhemH8pFEwBdAdG0LhrNkU0H" + b"WN1YpT55IiQ31esLdL5q-qDsOPpNZJUti1y1lAreM5nIn2srdWzGXGs4i" + b"TRQsn0XkNUCL4RErpciXmjfhMrPkcAjKA-mXQm2fa4jmTlEZFqFmUlym1" + b"ozJ0yf5grjN6AslN4OGvAv1pS-_Ko_pGBS6IQtSBC6vVKCUuBfaqNjykg" + b"bsxbLa6Fp0SYeYwO8ifEnkRvasVpc1WTQqfRB2JCj5pTBDzJpIpFCMmnQ" +) + +ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE = ( + "gl-python/3.7 auth/1.1 auth-request-type/at cred-type/mds" +) +ID_TOKEN_REQUEST_METRICS_HEADER_VALUE = ( + "gl-python/3.7 auth/1.1 auth-request-type/it cred-type/mds" +) +FAKE_SERVICE_ACCOUNT_EMAIL = "foo@bar.com" +FAKE_QUOTA_PROJECT_ID = "fake-quota-project" +FAKE_SCOPES = ["scope1", "scope2"] +FAKE_DEFAULT_SCOPES = ["scope3", "scope4"] +FAKE_UNIVERSE_DOMAIN = "fake-universe-domain" + + +class TestCredentials(object): + credentials = None + credentials_with_all_fields = None + VALID_TRUST_BOUNDARY = {"encodedLocations": "valid-encoded-locations"} + NO_OP_TRUST_BOUNDARY = {"encodedLocations": ""} + EXPECTED_TRUST_BOUNDARY_LOOKUP_URL_DEFAULT_UNIVERSE = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/default/allowedLocations" + + @pytest.fixture(autouse=True) + def credentials_fixture(self): + self.credentials = credentials.Credentials() + self.credentials_with_all_fields = credentials.Credentials( + service_account_email=FAKE_SERVICE_ACCOUNT_EMAIL, + quota_project_id=FAKE_QUOTA_PROJECT_ID, + scopes=FAKE_SCOPES, + default_scopes=FAKE_DEFAULT_SCOPES, + universe_domain=FAKE_UNIVERSE_DOMAIN, + ) + + def test_get_cred_info(self): + assert self.credentials.get_cred_info() == { + "credential_source": "metadata server", + "credential_type": "VM credentials", + "principal": "default", + } + + def test_default_state(self): + assert not self.credentials.valid + # Expiration hasn't been set yet + assert not self.credentials.expired + # Scopes are needed + assert self.credentials.requires_scopes + # Service account email hasn't been populated + assert self.credentials.service_account_email == "default" + # No quota project + assert not self.credentials._quota_project_id + # Universe domain is the default and not cached + assert self.credentials._universe_domain == "googleapis.com" + assert not self.credentials._universe_domain_cached + + @mock.patch( + "google.auth._helpers.utcnow", + return_value=datetime.datetime.min + _helpers.REFRESH_THRESHOLD, + ) + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) + def test_refresh_success(self, get, utcnow): + get.side_effect = [ + { + # First request is for sevice account info. + "email": "service-account@example.com", + "scopes": ["one", "two"], + }, + { + # Second request is for the token. + "access_token": "token", + "expires_in": 500, + }, + ] + + # Refresh credentials + self.credentials.refresh(None) + + # Check that the credentials have the token and proper expiration + assert self.credentials.token == "token" + assert self.credentials.expiry == (utcnow() + datetime.timedelta(seconds=500)) + + # Check the credential info + assert self.credentials.service_account_email == "service-account@example.com" + assert self.credentials._scopes == ["one", "two"] + + # Check that the credentials are valid (have a token and are not + # expired) + assert self.credentials.valid + + @mock.patch( + "google.auth.metrics.token_request_access_token_mds", + return_value=ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, + ) + @mock.patch( + "google.auth._helpers.utcnow", + return_value=datetime.datetime.min + _helpers.REFRESH_THRESHOLD, + ) + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) + def test_refresh_success_with_scopes(self, get, utcnow, mock_metrics_header_value): + get.side_effect = [ + { + # First request is for sevice account info. + "email": "service-account@example.com", + "scopes": ["one", "two"], + }, + { + # Second request is for the token. + "access_token": "token", + "expires_in": 500, + }, + ] + + # Refresh credentials + scopes = ["three", "four"] + self.credentials = self.credentials.with_scopes(scopes) + self.credentials.refresh(None) + + # Check that the credentials have the token and proper expiration + assert self.credentials.token == "token" + assert self.credentials.expiry == (utcnow() + datetime.timedelta(seconds=500)) + + # Check the credential info + assert self.credentials.service_account_email == "service-account@example.com" + assert self.credentials._scopes == scopes + + # Check that the credentials are valid (have a token and are not + # expired) + assert self.credentials.valid + + kwargs = get.call_args[1] + assert kwargs["params"] == {"scopes": "three,four"} + assert kwargs["headers"] == { + "x-goog-api-client": ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE + } + + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) + def test_refresh_no_email(self, get): + get.return_value = { + # No "email" field. + "scopes": ["one", "two"] + } + + with pytest.raises(exceptions.RefreshError) as excinfo: + self.credentials.refresh(None) + + assert excinfo.match(r"missing 'email' field") + + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) + def test_refresh_error(self, get): + get.side_effect = exceptions.TransportError("http error") + + with pytest.raises(exceptions.RefreshError) as excinfo: + self.credentials.refresh(None) + + assert excinfo.match(r"http error") + + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) + def test_before_request_refreshes(self, get): + get.side_effect = [ + { + # First request is for sevice account info. + "email": "service-account@example.com", + "scopes": "one two", + }, + { + # Second request is for the token. + "access_token": "token", + "expires_in": 500, + }, + ] + + # Credentials should start as invalid + assert not self.credentials.valid + + # before_request should cause a refresh + request = mock.create_autospec(transport.Request, instance=True) + self.credentials.before_request(request, "GET", "http://example.com?a=1#3", {}) + + # The refresh endpoint should've been called. + assert get.called + + # Credentials should now be valid. + assert self.credentials.valid + + def test_with_quota_project(self): + creds = self.credentials_with_all_fields.with_quota_project("project-foo") + + assert creds._quota_project_id == "project-foo" + assert creds._service_account_email == FAKE_SERVICE_ACCOUNT_EMAIL + assert creds._scopes == FAKE_SCOPES + assert creds._default_scopes == FAKE_DEFAULT_SCOPES + assert creds.universe_domain == FAKE_UNIVERSE_DOMAIN + assert creds._universe_domain_cached + + def test_with_scopes(self): + scopes = ["one", "two"] + creds = self.credentials_with_all_fields.with_scopes(scopes) + + assert creds._scopes == scopes + assert creds._quota_project_id == FAKE_QUOTA_PROJECT_ID + assert creds._service_account_email == FAKE_SERVICE_ACCOUNT_EMAIL + assert creds._default_scopes is None + assert creds.universe_domain == FAKE_UNIVERSE_DOMAIN + assert creds._universe_domain_cached + + def test_with_universe_domain(self): + creds = self.credentials_with_all_fields.with_universe_domain("universe_domain") + + assert creds._scopes == FAKE_SCOPES + assert creds._quota_project_id == FAKE_QUOTA_PROJECT_ID + assert creds._service_account_email == FAKE_SERVICE_ACCOUNT_EMAIL + assert creds._default_scopes == FAKE_DEFAULT_SCOPES + assert creds.universe_domain == "universe_domain" + assert creds._universe_domain_cached + + def test_with_trust_boundary(self): + creds = self.credentials_with_all_fields + new_boundary = {"encodedLocations": "new_boundary"} + new_creds = creds.with_trust_boundary(new_boundary) + + assert new_creds is not creds + assert new_creds._trust_boundary == new_boundary + assert new_creds._service_account_email == creds._service_account_email + assert new_creds._quota_project_id == creds._quota_project_id + assert new_creds._scopes == creds._scopes + assert new_creds._default_scopes == creds._default_scopes + + def test_token_usage_metrics(self): + self.credentials.token = "token" + self.credentials.expiry = None + + headers = {} + self.credentials.before_request(mock.Mock(), None, None, headers) + assert headers["authorization"] == "Bearer token" + assert headers["x-goog-api-client"] == "cred-type/mds" + + @mock.patch( + "google.auth.compute_engine._metadata.get_universe_domain", + return_value="fake_universe_domain", + ) + def test_universe_domain(self, get_universe_domain): + # Check the default state + assert not self.credentials._universe_domain_cached + assert self.credentials._universe_domain == "googleapis.com" + + # calling the universe_domain property should trigger a call to + # get_universe_domain to fetch the value. The value should be cached. + assert self.credentials.universe_domain == "fake_universe_domain" + assert self.credentials._universe_domain == "fake_universe_domain" + assert self.credentials._universe_domain_cached + get_universe_domain.assert_called_once() + + # calling the universe_domain property the second time should use the + # cached value instead of calling get_universe_domain + assert self.credentials.universe_domain == "fake_universe_domain" + get_universe_domain.assert_called_once() + + @mock.patch("google.auth.compute_engine._metadata.get_universe_domain") + def test_user_provided_universe_domain(self, get_universe_domain): + assert self.credentials_with_all_fields.universe_domain == FAKE_UNIVERSE_DOMAIN + assert self.credentials_with_all_fields._universe_domain_cached + + # Since user provided universe_domain, we will not call the universe + # domain endpoint. + get_universe_domain.assert_not_called() + + @mock.patch("google.oauth2._client._lookup_trust_boundary", autospec=True) + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) + def test_refresh_trust_boundary_lookup_skipped_if_env_var_not_true( + self, mock_metadata_get, mock_lookup_tb + ): + creds = self.credentials + request = mock.Mock() + + mock_metadata_get.side_effect = [ + # from _retrieve_info + {"email": "default", "scopes": ["scope1"]}, + # from get_service_account_token + {"access_token": "mock_token", "expires_in": 3600}, + ] + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "false"} + ): + creds.refresh(request) + + mock_lookup_tb.assert_not_called() + assert creds._trust_boundary is None + + @mock.patch("google.oauth2._client._lookup_trust_boundary", autospec=True) + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) + def test_refresh_trust_boundary_lookup_skipped_if_env_var_missing( + self, mock_metadata_get, mock_lookup_tb + ): + creds = self.credentials + request = mock.Mock() + + mock_metadata_get.side_effect = [ + # from _retrieve_info + {"email": "default", "scopes": ["scope1"]}, + # from get_service_account_token + {"access_token": "mock_token", "expires_in": 3600}, + ] + + with mock.patch.dict(os.environ, clear=True): + creds.refresh(request) + + mock_lookup_tb.assert_not_called() + assert creds._trust_boundary is None + + @mock.patch.object(_client, "_lookup_trust_boundary", autospec=True) + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) + def test_refresh_trust_boundary_lookup_success( + self, mock_metadata_get, mock_lookup_tb + ): + mock_lookup_tb.return_value = { + "locations": ["us-central1"], + "encodedLocations": "0xABC", + } + creds = self.credentials + request = mock.Mock() + + # The first call to _metadata.get is for service account info, the second + # for the access token, and the third for the universe domain. + mock_metadata_get.side_effect = [ + # from _retrieve_info + {"email": "resolved-email@example.com", "scopes": ["scope1"]}, + # from get_service_account_token + {"access_token": "mock_token", "expires_in": 3600}, + # from get_universe_domain + "", + ] + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + ): + creds.refresh(request) + + # Verify _metadata.get was called three times. + assert mock_metadata_get.call_count == 3 + # Verify lookup_trust_boundary was called with correct URL and token + expected_url = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/resolved-email@example.com/allowedLocations" + mock_lookup_tb.assert_called_once_with( + request, expected_url, headers={"authorization": "Bearer mock_token"} + ) + # Verify trust boundary was set + assert creds._trust_boundary == { + "locations": ["us-central1"], + "encodedLocations": "0xABC", + } + + # Verify x-allowed-locations header is set by apply() + headers_applied = {} + creds.apply(headers_applied) + assert headers_applied["x-allowed-locations"] == "0xABC" + + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) + @mock.patch.object(_client, "_lookup_trust_boundary", autospec=True) + def test_refresh_trust_boundary_lookup_fails_no_cache( + self, mock_lookup_tb, mock_metadata_get + ): + mock_lookup_tb.side_effect = exceptions.RefreshError("Lookup failed") + creds = self.credentials + request = mock.Mock() + + # Mock metadata calls for token, universe domain, and service account info + mock_metadata_get.side_effect = [ + # from _retrieve_info + {"email": "resolved-email@example.com", "scopes": ["scope1"]}, + # from get_service_account_token + {"access_token": "mock_token", "expires_in": 3600}, + # from get_universe_domain + "", + ] + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + ): + with pytest.raises(exceptions.RefreshError, match="Lookup failed"): + creds.refresh(request) + + assert creds._trust_boundary is None + assert mock_metadata_get.call_count == 3 + mock_lookup_tb.assert_called_once() + + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) + @mock.patch.object(_client, "_lookup_trust_boundary", autospec=True) + def test_refresh_trust_boundary_lookup_fails_with_cached_data( + self, mock_lookup_tb, mock_metadata_get + ): + # First refresh: Successfully fetch a valid trust boundary. + mock_lookup_tb.return_value = { + "locations": ["us-central1"], + "encodedLocations": "0xABC", + } + mock_metadata_get.side_effect = [ + # from _retrieve_info + {"email": "resolved-email@example.com", "scopes": ["scope1"]}, + # from get_service_account_token + {"access_token": "mock_token_1", "expires_in": 3600}, + # from get_universe_domain + "", + ] + creds = self.credentials + request = mock.Mock() + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + ): + creds.refresh(request) + + assert creds._trust_boundary == { + "locations": ["us-central1"], + "encodedLocations": "0xABC", + } + mock_lookup_tb.assert_called_once() + + # Second refresh: Mock lookup to fail, but expect cached data to be preserved. + mock_lookup_tb.reset_mock() + mock_lookup_tb.side_effect = exceptions.RefreshError("Lookup failed") + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + ): + # This refresh should not raise an error because a cached value exists. + mock_metadata_get.reset_mock() + mock_metadata_get.side_effect = [ + # from _retrieve_info + {"email": "resolved-email@example.com", "scopes": ["scope1"]}, + # from get_service_account_token + {"access_token": "mock_token_2", "expires_in": 3600}, + # from get_universe_domain + "", + ] + creds.refresh(request) + + assert creds._trust_boundary == { + "locations": ["us-central1"], + "encodedLocations": "0xABC", + } + mock_lookup_tb.assert_called_once() + + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) + @mock.patch.object(_client, "_lookup_trust_boundary", autospec=True) + def test_refresh_fetches_no_op_trust_boundary( + self, mock_lookup_tb, mock_metadata_get + ): + mock_lookup_tb.return_value = {"locations": [], "encodedLocations": "0x0"} + creds = self.credentials + request = mock.Mock() + + mock_metadata_get.side_effect = [ + # from _retrieve_info + {"email": "resolved-email@example.com", "scopes": ["scope1"]}, + # from get_service_account_token + {"access_token": "mock_token", "expires_in": 3600}, + # from get_universe_domain + "", + ] + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + ): + creds.refresh(request) + + assert creds._trust_boundary == {"locations": [], "encodedLocations": "0x0"} + assert mock_metadata_get.call_count == 3 + expected_url = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/resolved-email@example.com/allowedLocations" + mock_lookup_tb.assert_called_once_with( + request, expected_url, headers={"authorization": "Bearer mock_token"} + ) + # Verify that an empty header was added. + headers_applied = {} + creds.apply(headers_applied) + assert headers_applied["x-allowed-locations"] == "" + + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) + @mock.patch.object(_client, "_lookup_trust_boundary", autospec=True) + def test_refresh_starts_with_no_op_trust_boundary_skips_lookup( + self, mock_lookup_tb, mock_metadata_get + ): + creds = self.credentials + # Use pre-cache universe domain to avoid an extra metadata call. + creds._universe_domain_cached = True + creds._trust_boundary = {"locations": [], "encodedLocations": "0x0"} + request = mock.Mock() + + mock_metadata_get.side_effect = [ + # from _retrieve_info + {"email": "resolved-email@example.com", "scopes": ["scope1"]}, + # from get_service_account_token + {"access_token": "mock_token", "expires_in": 3600}, + ] + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + ): + creds.refresh(request) + + # Verify trust boundary remained NO_OP + assert creds._trust_boundary == {"locations": [], "encodedLocations": "0x0"} + # Lookup should be skipped + mock_lookup_tb.assert_not_called() + # Two metadata calls for token refresh should have happened. + assert mock_metadata_get.call_count == 2 + + # Verify that an empty header was added. + headers_applied = {} + creds.apply(headers_applied) + assert headers_applied["x-allowed-locations"] == "" + + @mock.patch( + "google.auth.compute_engine._metadata.get_service_account_info", autospec=True + ) + @mock.patch( + "google.auth.compute_engine._metadata.get_universe_domain", autospec=True + ) + def test_build_trust_boundary_lookup_url_default_email( + self, mock_get_universe_domain, mock_get_service_account_info + ): + # Test with default service account email, which needs resolution + creds = self.credentials + creds._service_account_email = "default" + mock_get_service_account_info.return_value = { + "email": "resolved-email@example.com" + } + mock_get_universe_domain.return_value = "googleapis.com" + + url = creds._build_trust_boundary_lookup_url() + + mock_get_service_account_info.assert_called_once_with(mock.ANY, "default") + mock_get_universe_domain.assert_called_once_with(mock.ANY) + assert url == ( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/resolved-email@example.com/allowedLocations" + ) + + @mock.patch( + "google.auth.compute_engine._metadata.get_service_account_info", autospec=True + ) + @mock.patch( + "google.auth.compute_engine._metadata.get_universe_domain", autospec=True + ) + def test_build_trust_boundary_lookup_url_explicit_email( + self, mock_get_universe_domain, mock_get_service_account_info + ): + # Test with an explicit service account email, no resolution needed + creds = self.credentials + creds._service_account_email = FAKE_SERVICE_ACCOUNT_EMAIL + mock_get_universe_domain.return_value = "googleapis.com" + + url = creds._build_trust_boundary_lookup_url() + + mock_get_service_account_info.assert_not_called() + mock_get_universe_domain.assert_called_once_with(mock.ANY) + assert url == ( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/foo@bar.com/allowedLocations" + ) + + @mock.patch( + "google.auth.compute_engine._metadata.get_service_account_info", autospec=True + ) + @mock.patch( + "google.auth.compute_engine._metadata.get_universe_domain", autospec=True + ) + def test_build_trust_boundary_lookup_url_non_default_universe( + self, mock_get_universe_domain, mock_get_service_account_info + ): + # Test with a non-default universe domain + creds = self.credentials_with_all_fields + + url = creds._build_trust_boundary_lookup_url() + + # Universe domain is cached and email is explicit, so no metadata calls needed. + mock_get_service_account_info.assert_not_called() + mock_get_universe_domain.assert_not_called() + assert url == ( + "https://iamcredentials.fake-universe-domain/v1/projects/-/serviceAccounts/foo@bar.com/allowedLocations" + ) + + @mock.patch( + "google.auth.compute_engine._metadata.get_service_account_info", autospec=True + ) + def test_build_trust_boundary_lookup_url_get_service_account_info_fails( + self, mock_get_service_account_info + ): + # Test scenario where get_service_account_info fails + mock_get_service_account_info.side_effect = exceptions.TransportError( + "Failed to get info" + ) + creds = self.credentials + creds._service_account_email = "default" + + with pytest.raises( + exceptions.RefreshError, + match=r"Failed to get service account email for trust boundary lookup: .*", + ): + creds._build_trust_boundary_lookup_url() + + @mock.patch( + "google.auth.compute_engine._metadata.get_service_account_info", autospec=True + ) + def test_build_trust_boundary_lookup_url_no_email( + self, mock_get_service_account_info + ): + # Test with default service account email, which needs resolution, but metadata + # returns no email. + creds = self.credentials + creds._service_account_email = "default" + mock_get_service_account_info.return_value = {"scopes": ["one", "two"]} + + with pytest.raises(exceptions.RefreshError) as excinfo: + creds._build_trust_boundary_lookup_url() + + assert excinfo.match(r"missing 'email' field") + + +class TestIDTokenCredentials(object): + credentials = None + + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) + def test_default_state(self, get): + get.side_effect = [ + {"email": "service-account@example.com", "scope": ["one", "two"]} + ] + + request = mock.create_autospec(transport.Request, instance=True) + self.credentials = credentials.IDTokenCredentials( + request=request, target_audience="https://example.com" + ) + + assert not self.credentials.valid + # Expiration hasn't been set yet + assert not self.credentials.expired + # Service account email hasn't been populated + assert self.credentials.service_account_email == "service-account@example.com" + # Signer is initialized + assert self.credentials.signer + assert self.credentials.signer_email == "service-account@example.com" + # No quota project + assert not self.credentials._quota_project_id + + @mock.patch( + "google.auth._helpers.utcnow", + return_value=datetime.datetime.utcfromtimestamp(0), + ) + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) + @mock.patch("google.auth.iam.Signer.sign", autospec=True) + def test_make_authorization_grant_assertion(self, sign, get, utcnow): + get.side_effect = [ + {"email": "service-account@example.com", "scopes": ["one", "two"]} + ] + sign.side_effect = [b"signature"] + + request = mock.create_autospec(transport.Request, instance=True) + self.credentials = credentials.IDTokenCredentials( + request=request, target_audience="https://audience.com" + ) + + # Generate authorization grant: + token = self.credentials._make_authorization_grant_assertion() + payload = jwt.decode(token, verify=False) + + # The JWT token signature is 'signature' encoded in base 64: + assert token.endswith(b".c2lnbmF0dXJl") + + # Check that the credentials have the token and proper expiration + assert payload == { + "aud": "https://www.googleapis.com/oauth2/v4/token", + "exp": 3600, + "iat": 0, + "iss": "service-account@example.com", + "target_audience": "https://audience.com", + } + + @mock.patch( + "google.auth._helpers.utcnow", + return_value=datetime.datetime.utcfromtimestamp(0), + ) + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) + @mock.patch("google.auth.iam.Signer.sign", autospec=True) + def test_with_service_account(self, sign, get, utcnow): + sign.side_effect = [b"signature"] + + request = mock.create_autospec(transport.Request, instance=True) + self.credentials = credentials.IDTokenCredentials( + request=request, + target_audience="https://audience.com", + service_account_email="service-account@other.com", + ) + + # Generate authorization grant: + token = self.credentials._make_authorization_grant_assertion() + payload = jwt.decode(token, verify=False) + + # The JWT token signature is 'signature' encoded in base 64: + assert token.endswith(b".c2lnbmF0dXJl") + + # Check that the credentials have the token and proper expiration + assert payload == { + "aud": "https://www.googleapis.com/oauth2/v4/token", + "exp": 3600, + "iat": 0, + "iss": "service-account@other.com", + "target_audience": "https://audience.com", + } + + @mock.patch( + "google.auth._helpers.utcnow", + return_value=datetime.datetime.utcfromtimestamp(0), + ) + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) + @mock.patch("google.auth.iam.Signer.sign", autospec=True) + def test_additional_claims(self, sign, get, utcnow): + get.side_effect = [ + {"email": "service-account@example.com", "scopes": ["one", "two"]} + ] + sign.side_effect = [b"signature"] + + request = mock.create_autospec(transport.Request, instance=True) + self.credentials = credentials.IDTokenCredentials( + request=request, + target_audience="https://audience.com", + additional_claims={"foo": "bar"}, + ) + + # Generate authorization grant: + token = self.credentials._make_authorization_grant_assertion() + payload = jwt.decode(token, verify=False) + + # The JWT token signature is 'signature' encoded in base 64: + assert token.endswith(b".c2lnbmF0dXJl") + + # Check that the credentials have the token and proper expiration + assert payload == { + "aud": "https://www.googleapis.com/oauth2/v4/token", + "exp": 3600, + "iat": 0, + "iss": "service-account@example.com", + "target_audience": "https://audience.com", + "foo": "bar", + } + + def test_token_uri(self): + request = mock.create_autospec(transport.Request, instance=True) + + self.credentials = credentials.IDTokenCredentials( + request=request, + signer=mock.Mock(), + service_account_email="foo@example.com", + target_audience="https://audience.com", + ) + assert self.credentials._token_uri == credentials._DEFAULT_TOKEN_URI + + self.credentials = credentials.IDTokenCredentials( + request=request, + signer=mock.Mock(), + service_account_email="foo@example.com", + target_audience="https://audience.com", + token_uri="https://example.com/token", + ) + assert self.credentials._token_uri == "https://example.com/token" + + @mock.patch( + "google.auth._helpers.utcnow", + return_value=datetime.datetime.utcfromtimestamp(0), + ) + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) + @mock.patch("google.auth.iam.Signer.sign", autospec=True) + def test_with_target_audience(self, sign, get, utcnow): + get.side_effect = [ + {"email": "service-account@example.com", "scopes": ["one", "two"]} + ] + sign.side_effect = [b"signature"] + + request = mock.create_autospec(transport.Request, instance=True) + self.credentials = credentials.IDTokenCredentials( + request=request, target_audience="https://audience.com" + ) + self.credentials = self.credentials.with_target_audience("https://actually.not") + + # Generate authorization grant: + token = self.credentials._make_authorization_grant_assertion() + payload = jwt.decode(token, verify=False) + + # The JWT token signature is 'signature' encoded in base 64: + assert token.endswith(b".c2lnbmF0dXJl") + + # Check that the credentials have the token and proper expiration + assert payload == { + "aud": "https://www.googleapis.com/oauth2/v4/token", + "exp": 3600, + "iat": 0, + "iss": "service-account@example.com", + "target_audience": "https://actually.not", + } + + # Check that the signer have been initialized with a Request object + assert isinstance(self.credentials._signer._request, transport.Request) + + @responses.activate + def test_with_target_audience_integration(self): + """Test that it is possible to refresh credentials + generated from `with_target_audience`. + + Instead of mocking the methods, the HTTP responses + have been mocked. + """ + + # mock information about credentials + responses.add( + responses.GET, + "http://metadata.google.internal/computeMetadata/v1/instance/" + "service-accounts/default/?recursive=true", + status=200, + content_type="application/json", + json={ + "scopes": "email", + "email": "service-account@example.com", + "aliases": ["default"], + }, + ) + + # mock information about universe_domain + responses.add( + responses.GET, + "http://metadata.google.internal/computeMetadata/v1/universe/" + "universe-domain", + status=200, + content_type="application/json", + json={}, + ) + + # mock token for credentials + responses.add( + responses.GET, + "http://metadata.google.internal/computeMetadata/v1/instance/" + "service-accounts/default/token", + status=200, + content_type="application/json", + json={ + "access_token": "some-token", + "expires_in": 3210, + "token_type": "Bearer", + }, + ) + + # mock sign blob endpoint + signature = base64.b64encode(b"some-signature").decode("utf-8") + responses.add( + responses.POST, + "https://iamcredentials.googleapis.com/v1/projects/-/" + "serviceAccounts/service-account@example.com:signBlob", + status=200, + content_type="application/json", + json={"keyId": "some-key-id", "signedBlob": signature}, + ) + + id_token = "{}.{}.{}".format( + base64.b64encode(b'{"some":"some"}').decode("utf-8"), + base64.b64encode(b'{"exp": 3210}').decode("utf-8"), + base64.b64encode(b"token").decode("utf-8"), + ) + + # mock id token endpoint + responses.add( + responses.POST, + "https://www.googleapis.com/oauth2/v4/token", + status=200, + content_type="application/json", + json={"id_token": id_token, "expiry": 3210}, + ) + + self.credentials = credentials.IDTokenCredentials( + request=requests.Request(), + service_account_email="service-account@example.com", + target_audience="https://audience.com", + ) + + self.credentials = self.credentials.with_target_audience("https://actually.not") + + self.credentials.refresh(requests.Request()) + + assert self.credentials.token is not None + + @mock.patch( + "google.auth._helpers.utcnow", + return_value=datetime.datetime.utcfromtimestamp(0), + ) + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) + @mock.patch("google.auth.iam.Signer.sign", autospec=True) + def test_with_quota_project(self, sign, get, utcnow): + get.side_effect = [ + {"email": "service-account@example.com", "scopes": ["one", "two"]} + ] + sign.side_effect = [b"signature"] + + request = mock.create_autospec(transport.Request, instance=True) + self.credentials = credentials.IDTokenCredentials( + request=request, target_audience="https://audience.com" + ) + self.credentials = self.credentials.with_quota_project("project-foo") + + assert self.credentials._quota_project_id == "project-foo" + + # Generate authorization grant: + token = self.credentials._make_authorization_grant_assertion() + payload = jwt.decode(token, verify=False) + + # The JWT token signature is 'signature' encoded in base 64: + assert token.endswith(b".c2lnbmF0dXJl") + + # Check that the credentials have the token and proper expiration + assert payload == { + "aud": "https://www.googleapis.com/oauth2/v4/token", + "exp": 3600, + "iat": 0, + "iss": "service-account@example.com", + "target_audience": "https://audience.com", + } + + # Check that the signer have been initialized with a Request object + assert isinstance(self.credentials._signer._request, transport.Request) + + @mock.patch( + "google.auth._helpers.utcnow", + return_value=datetime.datetime.utcfromtimestamp(0), + ) + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) + @mock.patch("google.auth.iam.Signer.sign", autospec=True) + def test_with_token_uri(self, sign, get, utcnow): + get.side_effect = [ + {"email": "service-account@example.com", "scopes": ["one", "two"]} + ] + sign.side_effect = [b"signature"] + + request = mock.create_autospec(transport.Request, instance=True) + self.credentials = credentials.IDTokenCredentials( + request=request, + target_audience="https://audience.com", + token_uri="http://xyz.com", + ) + assert self.credentials._token_uri == "http://xyz.com" + creds_with_token_uri = self.credentials.with_token_uri("http://example.com") + assert creds_with_token_uri._token_uri == "http://example.com" + + @mock.patch( + "google.auth._helpers.utcnow", + return_value=datetime.datetime.utcfromtimestamp(0), + ) + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) + @mock.patch("google.auth.iam.Signer.sign", autospec=True) + def test_with_token_uri_exception(self, sign, get, utcnow): + get.side_effect = [ + {"email": "service-account@example.com", "scopes": ["one", "two"]} + ] + sign.side_effect = [b"signature"] + + request = mock.create_autospec(transport.Request, instance=True) + self.credentials = credentials.IDTokenCredentials( + request=request, + target_audience="https://audience.com", + use_metadata_identity_endpoint=True, + ) + assert self.credentials._token_uri is None + with pytest.raises(ValueError): + self.credentials.with_token_uri("http://example.com") + + @responses.activate + def test_with_quota_project_integration(self): + """Test that it is possible to refresh credentials + generated from `with_quota_project`. + + Instead of mocking the methods, the HTTP responses + have been mocked. + """ + + # mock information about credentials + responses.add( + responses.GET, + "http://metadata.google.internal/computeMetadata/v1/instance/" + "service-accounts/default/?recursive=true", + status=200, + content_type="application/json", + json={ + "scopes": "email", + "email": "service-account@example.com", + "aliases": ["default"], + }, + ) + + # mock token for credentials + responses.add( + responses.GET, + "http://metadata.google.internal/computeMetadata/v1/instance/" + "service-accounts/default/token", + status=200, + content_type="application/json", + json={ + "access_token": "some-token", + "expires_in": 3210, + "token_type": "Bearer", + }, + ) + + # stubby response about universe_domain + responses.add( + responses.GET, + "http://metadata.google.internal/computeMetadata/v1/universe/" + "universe-domain", + status=200, + content_type="application/json", + json={}, + ) + + # mock sign blob endpoint + signature = base64.b64encode(b"some-signature").decode("utf-8") + responses.add( + responses.POST, + "https://iamcredentials.googleapis.com/v1/projects/-/" + "serviceAccounts/service-account@example.com:signBlob", + status=200, + content_type="application/json", + json={"keyId": "some-key-id", "signedBlob": signature}, + ) + + id_token = "{}.{}.{}".format( + base64.b64encode(b'{"some":"some"}').decode("utf-8"), + base64.b64encode(b'{"exp": 3210}').decode("utf-8"), + base64.b64encode(b"token").decode("utf-8"), + ) + + # mock id token endpoint + responses.add( + responses.POST, + "https://www.googleapis.com/oauth2/v4/token", + status=200, + content_type="application/json", + json={"id_token": id_token, "expiry": 3210}, + ) + + self.credentials = credentials.IDTokenCredentials( + request=requests.Request(), + service_account_email="service-account@example.com", + target_audience="https://audience.com", + ) + + self.credentials = self.credentials.with_quota_project("project-foo") + + self.credentials.refresh(requests.Request()) + + assert self.credentials.token is not None + assert self.credentials._quota_project_id == "project-foo" + + @mock.patch( + "google.auth._helpers.utcnow", + return_value=datetime.datetime.utcfromtimestamp(0), + ) + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) + @mock.patch("google.auth.iam.Signer.sign", autospec=True) + @mock.patch("google.oauth2._client.id_token_jwt_grant", autospec=True) + def test_refresh_success(self, id_token_jwt_grant, sign, get, utcnow): + get.side_effect = [ + {"email": "service-account@example.com", "scopes": ["one", "two"]} + ] + sign.side_effect = [b"signature"] + id_token_jwt_grant.side_effect = [ + ("idtoken", datetime.datetime.utcfromtimestamp(3600), {}) + ] + + request = mock.create_autospec(transport.Request, instance=True) + self.credentials = credentials.IDTokenCredentials( + request=request, target_audience="https://audience.com" + ) + + # Refresh credentials + self.credentials.refresh(None) + + # Check that the credentials have the token and proper expiration + assert self.credentials.token == "idtoken" + assert self.credentials.expiry == (datetime.datetime.utcfromtimestamp(3600)) + + # Check the credential info + assert self.credentials.service_account_email == "service-account@example.com" + + # Check that the credentials are valid (have a token and are not + # expired) + assert self.credentials.valid + + @mock.patch( + "google.auth._helpers.utcnow", + return_value=datetime.datetime.utcfromtimestamp(0), + ) + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) + @mock.patch("google.auth.iam.Signer.sign", autospec=True) + def test_refresh_error(self, sign, get, utcnow): + get.side_effect = [ + {"email": "service-account@example.com", "scopes": ["one", "two"]} + ] + sign.side_effect = [b"signature"] + + request = mock.create_autospec(transport.Request, instance=True) + response = mock.Mock() + response.data = b'{"error": "http error"}' + response.status = 404 # Throw a 404 so the request is not retried. + request.side_effect = [response] + + self.credentials = credentials.IDTokenCredentials( + request=request, target_audience="https://audience.com" + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + self.credentials.refresh(request) + + assert excinfo.match(r"http error") + + @mock.patch( + "google.auth._helpers.utcnow", + return_value=datetime.datetime.utcfromtimestamp(0), + ) + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) + @mock.patch("google.auth.iam.Signer.sign", autospec=True) + @mock.patch("google.oauth2._client.id_token_jwt_grant", autospec=True) + def test_before_request_refreshes(self, id_token_jwt_grant, sign, get, utcnow): + get.side_effect = [ + {"email": "service-account@example.com", "scopes": "one two"} + ] + sign.side_effect = [b"signature"] + id_token_jwt_grant.side_effect = [ + ("idtoken", datetime.datetime.utcfromtimestamp(3600), {}) + ] + + request = mock.create_autospec(transport.Request, instance=True) + self.credentials = credentials.IDTokenCredentials( + request=request, target_audience="https://audience.com" + ) + + # Credentials should start as invalid + assert not self.credentials.valid + + # before_request should cause a refresh + request = mock.create_autospec(transport.Request, instance=True) + self.credentials.before_request(request, "GET", "http://example.com?a=1#3", {}) + + # The refresh endpoint should've been called. + assert get.called + + # Credentials should now be valid. + assert self.credentials.valid + + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) + @mock.patch("google.auth.iam.Signer.sign", autospec=True) + def test_sign_bytes(self, sign, get): + get.side_effect = [ + {"email": "service-account@example.com", "scopes": ["one", "two"]} + ] + sign.side_effect = [b"signature"] + + request = mock.create_autospec(transport.Request, instance=True) + response = mock.Mock() + response.data = b'{"signature": "c2lnbmF0dXJl"}' + response.status = 200 + request.side_effect = [response] + + self.credentials = credentials.IDTokenCredentials( + request=request, target_audience="https://audience.com" + ) + + # Generate authorization grant: + signature = self.credentials.sign_bytes(b"some bytes") + + # The JWT token signature is 'signature' encoded in base 64: + assert signature == b"signature" + + @mock.patch( + "google.auth.metrics.token_request_id_token_mds", + return_value=ID_TOKEN_REQUEST_METRICS_HEADER_VALUE, + ) + @mock.patch( + "google.auth.compute_engine._metadata.get_service_account_info", autospec=True + ) + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) + def test_get_id_token_from_metadata( + self, get, get_service_account_info, mock_metrics_header_value + ): + get.return_value = SAMPLE_ID_TOKEN + get_service_account_info.return_value = {"email": "foo@example.com"} + + cred = credentials.IDTokenCredentials( + mock.Mock(), "audience", use_metadata_identity_endpoint=True + ) + cred.refresh(request=mock.Mock()) + + assert get.call_args.kwargs["headers"] == { + "x-goog-api-client": ID_TOKEN_REQUEST_METRICS_HEADER_VALUE + } + + assert cred.token == SAMPLE_ID_TOKEN + assert cred.expiry == datetime.datetime.utcfromtimestamp(SAMPLE_ID_TOKEN_EXP) + assert cred._use_metadata_identity_endpoint + assert cred._signer is None + assert cred._token_uri is None + assert cred._service_account_email == "foo@example.com" + assert cred._target_audience == "audience" + with pytest.raises(ValueError): + cred.sign_bytes(b"bytes") + + @mock.patch( + "google.auth.compute_engine._metadata.get_service_account_info", autospec=True + ) + def test_with_target_audience_for_metadata(self, get_service_account_info): + get_service_account_info.return_value = {"email": "foo@example.com"} + + cred = credentials.IDTokenCredentials( + mock.Mock(), "audience", use_metadata_identity_endpoint=True + ) + cred = cred.with_target_audience("new_audience") + + assert cred._target_audience == "new_audience" + assert cred._use_metadata_identity_endpoint + assert cred._signer is None + assert cred._token_uri is None + assert cred._service_account_email == "foo@example.com" + + @mock.patch( + "google.auth.compute_engine._metadata.get_service_account_info", autospec=True + ) + def test_id_token_with_quota_project(self, get_service_account_info): + get_service_account_info.return_value = {"email": "foo@example.com"} + + cred = credentials.IDTokenCredentials( + mock.Mock(), "audience", use_metadata_identity_endpoint=True + ) + cred = cred.with_quota_project("project-foo") + + assert cred._quota_project_id == "project-foo" + assert cred._use_metadata_identity_endpoint + assert cred._signer is None + assert cred._token_uri is None + assert cred._service_account_email == "foo@example.com" + + @mock.patch( + "google.auth.compute_engine._metadata.get_service_account_info", autospec=True + ) + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) + def test_invalid_id_token_from_metadata(self, get, get_service_account_info): + get.return_value = "invalid_id_token" + get_service_account_info.return_value = {"email": "foo@example.com"} + + cred = credentials.IDTokenCredentials( + mock.Mock(), "audience", use_metadata_identity_endpoint=True + ) + + with pytest.raises(ValueError): + cred.refresh(request=mock.Mock()) + + @mock.patch( + "google.auth.compute_engine._metadata.get_service_account_info", autospec=True + ) + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) + def test_transport_error_from_metadata(self, get, get_service_account_info): + get.side_effect = exceptions.TransportError("transport error") + get_service_account_info.return_value = {"email": "foo@example.com"} + + cred = credentials.IDTokenCredentials( + mock.Mock(), "audience", use_metadata_identity_endpoint=True + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + cred.refresh(request=mock.Mock()) + assert excinfo.match(r"transport error") + + def test_get_id_token_from_metadata_constructor(self): + with pytest.raises(ValueError): + credentials.IDTokenCredentials( + mock.Mock(), + "audience", + use_metadata_identity_endpoint=True, + token_uri="token_uri", + ) + with pytest.raises(ValueError): + credentials.IDTokenCredentials( + mock.Mock(), + "audience", + use_metadata_identity_endpoint=True, + signer=mock.Mock(), + ) + with pytest.raises(ValueError): + credentials.IDTokenCredentials( + mock.Mock(), + "audience", + use_metadata_identity_endpoint=True, + additional_claims={"key", "value"}, + ) + with pytest.raises(ValueError): + credentials.IDTokenCredentials( + mock.Mock(), + "audience", + use_metadata_identity_endpoint=True, + service_account_email="foo@example.com", + ) diff --git a/packages/google-auth/tests/conftest.py b/packages/google-auth/tests/conftest.py new file mode 100644 index 000000000000..8080ec3fa26a --- /dev/null +++ b/packages/google-auth/tests/conftest.py @@ -0,0 +1,49 @@ +# Copyright 2016 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 os +import sys + +import mock +import pytest # type: ignore + + +def pytest_configure(): + """Load public certificate and private key.""" + pytest.data_dir = os.path.join(os.path.dirname(__file__), "data") + + with open(os.path.join(pytest.data_dir, "privatekey.pem"), "rb") as fh: + pytest.private_key_bytes = fh.read() + + with open(os.path.join(pytest.data_dir, "public_cert.pem"), "rb") as fh: + pytest.public_cert_bytes = fh.read() + + +@pytest.fixture +def mock_non_existent_module(monkeypatch): + """Mocks a non-existing module in sys.modules. + + Additionally mocks any non-existing modules specified in the dotted path. + """ + + def _mock_non_existent_module(path): + parts = path.split(".") + partial = [] + for part in parts: + partial.append(part) + current_module = ".".join(partial) + if current_module not in sys.modules: + monkeypatch.setitem(sys.modules, current_module, mock.MagicMock()) + + return _mock_non_existent_module diff --git a/packages/google-auth/tests/crypt/__init__.py b/packages/google-auth/tests/crypt/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/google-auth/tests/crypt/test__cryptography_rsa.py b/packages/google-auth/tests/crypt/test__cryptography_rsa.py new file mode 100644 index 000000000000..7f5406b626a6 --- /dev/null +++ b/packages/google-auth/tests/crypt/test__cryptography_rsa.py @@ -0,0 +1,176 @@ +# Copyright 2016 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 json +import os +import pickle + +from cryptography.hazmat.primitives.asymmetric import rsa +import pytest # type: ignore + +from google.auth import _helpers +from google.auth.crypt import _cryptography_rsa +from google.auth.crypt import base + + +DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "data") + +# To generate privatekey.pem, privatekey.pub, and public_cert.pem: +# $ openssl req -new -newkey rsa:1024 -x509 -nodes -out public_cert.pem \ +# > -keyout privatekey.pem +# $ openssl rsa -in privatekey.pem -pubout -out privatekey.pub + +with open(os.path.join(DATA_DIR, "privatekey.pem"), "rb") as fh: + PRIVATE_KEY_BYTES = fh.read() + PKCS1_KEY_BYTES = PRIVATE_KEY_BYTES + +with open(os.path.join(DATA_DIR, "privatekey.pub"), "rb") as fh: + PUBLIC_KEY_BYTES = fh.read() + +with open(os.path.join(DATA_DIR, "public_cert.pem"), "rb") as fh: + PUBLIC_CERT_BYTES = fh.read() + +# To generate pem_from_pkcs12.pem and privatekey.p12: +# $ openssl pkcs12 -export -out privatekey.p12 -inkey privatekey.pem \ +# > -in public_cert.pem +# $ openssl pkcs12 -in privatekey.p12 -nocerts -nodes \ +# > -out pem_from_pkcs12.pem + +with open(os.path.join(DATA_DIR, "pem_from_pkcs12.pem"), "rb") as fh: + PKCS8_KEY_BYTES = fh.read() + +with open(os.path.join(DATA_DIR, "privatekey.p12"), "rb") as fh: + PKCS12_KEY_BYTES = fh.read() + +# The service account JSON file can be generated from the Google Cloud Console. +SERVICE_ACCOUNT_JSON_FILE = os.path.join(DATA_DIR, "service_account.json") + +with open(SERVICE_ACCOUNT_JSON_FILE, "rb") as fh: + SERVICE_ACCOUNT_INFO = json.load(fh) + + +class TestRSAVerifier(object): + def test_verify_success(self): + to_sign = b"foo" + signer = _cryptography_rsa.RSASigner.from_string(PRIVATE_KEY_BYTES) + actual_signature = signer.sign(to_sign) + + verifier = _cryptography_rsa.RSAVerifier.from_string(PUBLIC_KEY_BYTES) + assert verifier.verify(to_sign, actual_signature) + + def test_verify_unicode_success(self): + to_sign = "foo" + signer = _cryptography_rsa.RSASigner.from_string(PRIVATE_KEY_BYTES) + actual_signature = signer.sign(to_sign) + + verifier = _cryptography_rsa.RSAVerifier.from_string(PUBLIC_KEY_BYTES) + assert verifier.verify(to_sign, actual_signature) + + def test_verify_failure(self): + verifier = _cryptography_rsa.RSAVerifier.from_string(PUBLIC_KEY_BYTES) + bad_signature1 = b"" + assert not verifier.verify(b"foo", bad_signature1) + bad_signature2 = b"a" + assert not verifier.verify(b"foo", bad_signature2) + + def test_from_string_pub_key(self): + verifier = _cryptography_rsa.RSAVerifier.from_string(PUBLIC_KEY_BYTES) + assert isinstance(verifier, _cryptography_rsa.RSAVerifier) + assert isinstance(verifier._pubkey, rsa.RSAPublicKey) + + def test_from_string_pub_key_unicode(self): + public_key = _helpers.from_bytes(PUBLIC_KEY_BYTES) + verifier = _cryptography_rsa.RSAVerifier.from_string(public_key) + assert isinstance(verifier, _cryptography_rsa.RSAVerifier) + assert isinstance(verifier._pubkey, rsa.RSAPublicKey) + + def test_from_string_pub_cert(self): + verifier = _cryptography_rsa.RSAVerifier.from_string(PUBLIC_CERT_BYTES) + assert isinstance(verifier, _cryptography_rsa.RSAVerifier) + assert isinstance(verifier._pubkey, rsa.RSAPublicKey) + + def test_from_string_pub_cert_unicode(self): + public_cert = _helpers.from_bytes(PUBLIC_CERT_BYTES) + verifier = _cryptography_rsa.RSAVerifier.from_string(public_cert) + assert isinstance(verifier, _cryptography_rsa.RSAVerifier) + assert isinstance(verifier._pubkey, rsa.RSAPublicKey) + + +class TestRSASigner(object): + def test_from_string_pkcs1(self): + signer = _cryptography_rsa.RSASigner.from_string(PKCS1_KEY_BYTES) + assert isinstance(signer, _cryptography_rsa.RSASigner) + assert isinstance(signer._key, rsa.RSAPrivateKey) + + def test_from_string_pkcs1_unicode(self): + key_bytes = _helpers.from_bytes(PKCS1_KEY_BYTES) + signer = _cryptography_rsa.RSASigner.from_string(key_bytes) + assert isinstance(signer, _cryptography_rsa.RSASigner) + assert isinstance(signer._key, rsa.RSAPrivateKey) + + def test_from_string_pkcs8(self): + signer = _cryptography_rsa.RSASigner.from_string(PKCS8_KEY_BYTES) + assert isinstance(signer, _cryptography_rsa.RSASigner) + assert isinstance(signer._key, rsa.RSAPrivateKey) + + def test_from_string_pkcs8_unicode(self): + key_bytes = _helpers.from_bytes(PKCS8_KEY_BYTES) + signer = _cryptography_rsa.RSASigner.from_string(key_bytes) + assert isinstance(signer, _cryptography_rsa.RSASigner) + assert isinstance(signer._key, rsa.RSAPrivateKey) + + def test_from_string_pkcs12(self): + with pytest.raises(ValueError): + _cryptography_rsa.RSASigner.from_string(PKCS12_KEY_BYTES) + + def test_from_string_bogus_key(self): + key_bytes = "bogus-key" + with pytest.raises(ValueError): + _cryptography_rsa.RSASigner.from_string(key_bytes) + + def test_from_service_account_info(self): + signer = _cryptography_rsa.RSASigner.from_service_account_info( + SERVICE_ACCOUNT_INFO + ) + + assert signer.key_id == SERVICE_ACCOUNT_INFO[base._JSON_FILE_PRIVATE_KEY_ID] + assert isinstance(signer._key, rsa.RSAPrivateKey) + + def test_from_service_account_info_missing_key(self): + with pytest.raises(ValueError) as excinfo: + _cryptography_rsa.RSASigner.from_service_account_info({}) + + assert excinfo.match(base._JSON_FILE_PRIVATE_KEY) + + def test_from_service_account_file(self): + signer = _cryptography_rsa.RSASigner.from_service_account_file( + SERVICE_ACCOUNT_JSON_FILE + ) + + assert signer.key_id == SERVICE_ACCOUNT_INFO[base._JSON_FILE_PRIVATE_KEY_ID] + assert isinstance(signer._key, rsa.RSAPrivateKey) + + def test_pickle(self): + signer = _cryptography_rsa.RSASigner.from_service_account_file( + SERVICE_ACCOUNT_JSON_FILE + ) + + assert signer.key_id == SERVICE_ACCOUNT_INFO[base._JSON_FILE_PRIVATE_KEY_ID] + assert isinstance(signer._key, rsa.RSAPrivateKey) + + pickled_signer = pickle.dumps(signer) + signer = pickle.loads(pickled_signer) + + assert signer.key_id == SERVICE_ACCOUNT_INFO[base._JSON_FILE_PRIVATE_KEY_ID] + assert isinstance(signer._key, rsa.RSAPrivateKey) diff --git a/packages/google-auth/tests/crypt/test__python_rsa.py b/packages/google-auth/tests/crypt/test__python_rsa.py new file mode 100644 index 000000000000..451c2834155e --- /dev/null +++ b/packages/google-auth/tests/crypt/test__python_rsa.py @@ -0,0 +1,193 @@ +# Copyright 2016 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 io +import json +import os + +import mock +from pyasn1_modules import pem # type: ignore +import pytest # type: ignore +import rsa # type: ignore + +from google.auth import _helpers +from google.auth.crypt import _python_rsa +from google.auth.crypt import base + + +DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "data") + +# To generate privatekey.pem, privatekey.pub, and public_cert.pem: +# $ openssl req -new -newkey rsa:1024 -x509 -nodes -out public_cert.pem \ +# > -keyout privatekey.pem +# $ openssl rsa -in privatekey.pem -pubout -out privatekey.pub + +with open(os.path.join(DATA_DIR, "privatekey.pem"), "rb") as fh: + PRIVATE_KEY_BYTES = fh.read() + PKCS1_KEY_BYTES = PRIVATE_KEY_BYTES + +with open(os.path.join(DATA_DIR, "privatekey.pub"), "rb") as fh: + PUBLIC_KEY_BYTES = fh.read() + +with open(os.path.join(DATA_DIR, "public_cert.pem"), "rb") as fh: + PUBLIC_CERT_BYTES = fh.read() + +# To generate pem_from_pkcs12.pem and privatekey.p12: +# $ openssl pkcs12 -export -out privatekey.p12 -inkey privatekey.pem \ +# > -in public_cert.pem +# $ openssl pkcs12 -in privatekey.p12 -nocerts -nodes \ +# > -out pem_from_pkcs12.pem + +with open(os.path.join(DATA_DIR, "pem_from_pkcs12.pem"), "rb") as fh: + PKCS8_KEY_BYTES = fh.read() + +with open(os.path.join(DATA_DIR, "privatekey.p12"), "rb") as fh: + PKCS12_KEY_BYTES = fh.read() + +# The service account JSON file can be generated from the Google Cloud Console. +SERVICE_ACCOUNT_JSON_FILE = os.path.join(DATA_DIR, "service_account.json") + +with open(SERVICE_ACCOUNT_JSON_FILE, "rb") as fh: + SERVICE_ACCOUNT_INFO = json.load(fh) + + +class TestRSAVerifier(object): + def test_verify_success(self): + to_sign = b"foo" + signer = _python_rsa.RSASigner.from_string(PRIVATE_KEY_BYTES) + actual_signature = signer.sign(to_sign) + + verifier = _python_rsa.RSAVerifier.from_string(PUBLIC_KEY_BYTES) + assert verifier.verify(to_sign, actual_signature) + + def test_verify_unicode_success(self): + to_sign = "foo" + signer = _python_rsa.RSASigner.from_string(PRIVATE_KEY_BYTES) + actual_signature = signer.sign(to_sign) + + verifier = _python_rsa.RSAVerifier.from_string(PUBLIC_KEY_BYTES) + assert verifier.verify(to_sign, actual_signature) + + def test_verify_failure(self): + verifier = _python_rsa.RSAVerifier.from_string(PUBLIC_KEY_BYTES) + bad_signature1 = b"" + assert not verifier.verify(b"foo", bad_signature1) + bad_signature2 = b"a" + assert not verifier.verify(b"foo", bad_signature2) + + def test_from_string_pub_key(self): + verifier = _python_rsa.RSAVerifier.from_string(PUBLIC_KEY_BYTES) + assert isinstance(verifier, _python_rsa.RSAVerifier) + assert isinstance(verifier._pubkey, rsa.key.PublicKey) + + def test_from_string_pub_key_unicode(self): + public_key = _helpers.from_bytes(PUBLIC_KEY_BYTES) + verifier = _python_rsa.RSAVerifier.from_string(public_key) + assert isinstance(verifier, _python_rsa.RSAVerifier) + assert isinstance(verifier._pubkey, rsa.key.PublicKey) + + def test_from_string_pub_cert(self): + verifier = _python_rsa.RSAVerifier.from_string(PUBLIC_CERT_BYTES) + assert isinstance(verifier, _python_rsa.RSAVerifier) + assert isinstance(verifier._pubkey, rsa.key.PublicKey) + + def test_from_string_pub_cert_unicode(self): + public_cert = _helpers.from_bytes(PUBLIC_CERT_BYTES) + verifier = _python_rsa.RSAVerifier.from_string(public_cert) + assert isinstance(verifier, _python_rsa.RSAVerifier) + assert isinstance(verifier._pubkey, rsa.key.PublicKey) + + def test_from_string_pub_cert_failure(self): + cert_bytes = PUBLIC_CERT_BYTES + true_der = rsa.pem.load_pem(cert_bytes, "CERTIFICATE") + load_pem_patch = mock.patch( + "rsa.pem.load_pem", return_value=true_der + b"extra", autospec=True + ) + + with load_pem_patch as load_pem: + with pytest.raises(ValueError): + _python_rsa.RSAVerifier.from_string(cert_bytes) + load_pem.assert_called_once_with(cert_bytes, "CERTIFICATE") + + +class TestRSASigner(object): + def test_from_string_pkcs1(self): + signer = _python_rsa.RSASigner.from_string(PKCS1_KEY_BYTES) + assert isinstance(signer, _python_rsa.RSASigner) + assert isinstance(signer._key, rsa.key.PrivateKey) + + def test_from_string_pkcs1_unicode(self): + key_bytes = _helpers.from_bytes(PKCS1_KEY_BYTES) + signer = _python_rsa.RSASigner.from_string(key_bytes) + assert isinstance(signer, _python_rsa.RSASigner) + assert isinstance(signer._key, rsa.key.PrivateKey) + + def test_from_string_pkcs8(self): + signer = _python_rsa.RSASigner.from_string(PKCS8_KEY_BYTES) + assert isinstance(signer, _python_rsa.RSASigner) + assert isinstance(signer._key, rsa.key.PrivateKey) + + def test_from_string_pkcs8_extra_bytes(self): + key_bytes = PKCS8_KEY_BYTES + _, pem_bytes = pem.readPemBlocksFromFile( + io.StringIO(_helpers.from_bytes(key_bytes)), _python_rsa._PKCS8_MARKER + ) + + key_info, remaining = None, "extra" + decode_patch = mock.patch( + "pyasn1.codec.der.decoder.decode", + return_value=(key_info, remaining), + autospec=True, + ) + + with decode_patch as decode: + with pytest.raises(ValueError): + _python_rsa.RSASigner.from_string(key_bytes) + # Verify mock was called. + decode.assert_called_once_with(pem_bytes, asn1Spec=_python_rsa._PKCS8_SPEC) + + def test_from_string_pkcs8_unicode(self): + key_bytes = _helpers.from_bytes(PKCS8_KEY_BYTES) + signer = _python_rsa.RSASigner.from_string(key_bytes) + assert isinstance(signer, _python_rsa.RSASigner) + assert isinstance(signer._key, rsa.key.PrivateKey) + + def test_from_string_pkcs12(self): + with pytest.raises(ValueError): + _python_rsa.RSASigner.from_string(PKCS12_KEY_BYTES) + + def test_from_string_bogus_key(self): + key_bytes = "bogus-key" + with pytest.raises(ValueError): + _python_rsa.RSASigner.from_string(key_bytes) + + def test_from_service_account_info(self): + signer = _python_rsa.RSASigner.from_service_account_info(SERVICE_ACCOUNT_INFO) + + assert signer.key_id == SERVICE_ACCOUNT_INFO[base._JSON_FILE_PRIVATE_KEY_ID] + assert isinstance(signer._key, rsa.key.PrivateKey) + + def test_from_service_account_info_missing_key(self): + with pytest.raises(ValueError) as excinfo: + _python_rsa.RSASigner.from_service_account_info({}) + + assert excinfo.match(base._JSON_FILE_PRIVATE_KEY) + + def test_from_service_account_file(self): + signer = _python_rsa.RSASigner.from_service_account_file( + SERVICE_ACCOUNT_JSON_FILE + ) + + assert signer.key_id == SERVICE_ACCOUNT_INFO[base._JSON_FILE_PRIVATE_KEY_ID] + assert isinstance(signer._key, rsa.key.PrivateKey) diff --git a/packages/google-auth/tests/crypt/test_crypt.py b/packages/google-auth/tests/crypt/test_crypt.py new file mode 100644 index 000000000000..e80502e9be59 --- /dev/null +++ b/packages/google-auth/tests/crypt/test_crypt.py @@ -0,0 +1,58 @@ +# Copyright 2016 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 os + +from google.auth import crypt + + +DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "data") + +# To generate privatekey.pem, privatekey.pub, and public_cert.pem: +# $ openssl req -new -newkey rsa:1024 -x509 -nodes -out public_cert.pem \ +# > -keyout privatekey.pem +# $ openssl rsa -in privatekey.pem -pubout -out privatekey.pub + +with open(os.path.join(DATA_DIR, "privatekey.pem"), "rb") as fh: + PRIVATE_KEY_BYTES = fh.read() + +with open(os.path.join(DATA_DIR, "public_cert.pem"), "rb") as fh: + PUBLIC_CERT_BYTES = fh.read() + +# To generate other_cert.pem: +# $ openssl req -new -newkey rsa:1024 -x509 -nodes -out other_cert.pem + +with open(os.path.join(DATA_DIR, "other_cert.pem"), "rb") as fh: + OTHER_CERT_BYTES = fh.read() + + +def test_verify_signature(): + to_sign = b"foo" + signer = crypt.RSASigner.from_string(PRIVATE_KEY_BYTES) + signature = signer.sign(to_sign) + + assert crypt.verify_signature(to_sign, signature, PUBLIC_CERT_BYTES) + + # List of certs + assert crypt.verify_signature( + to_sign, signature, [OTHER_CERT_BYTES, PUBLIC_CERT_BYTES] + ) + + +def test_verify_signature_failure(): + to_sign = b"foo" + signer = crypt.RSASigner.from_string(PRIVATE_KEY_BYTES) + signature = signer.sign(to_sign) + + assert not crypt.verify_signature(to_sign, signature, OTHER_CERT_BYTES) diff --git a/packages/google-auth/tests/crypt/test_es.py b/packages/google-auth/tests/crypt/test_es.py new file mode 100644 index 000000000000..fdbf9d2edfef --- /dev/null +++ b/packages/google-auth/tests/crypt/test_es.py @@ -0,0 +1,173 @@ +# Copyright 2016 Google Inc. +# +# 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 base64 +import json +import os +import pickle + +from cryptography.hazmat.primitives.asymmetric import ec +import pytest # type: ignore + +from google.auth import _helpers +from google.auth.crypt import base +from google.auth.crypt import es + + +DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "data") + +# To generate es384_privatekey.pem, es384_privatekey.pub, and +# es384_public_cert.pem: +# $ openssl ecparam -genkey -name secp384r1 -noout -out es384_privatekey.pem +# $ openssl ec -in es384_privatekey.pem -pubout -out es384_publickey.pem +# $ openssl req -new -x509 -key es384_privatekey.pem -out \ +# > es384_public_cert.pem + +with open(os.path.join(DATA_DIR, "es384_privatekey.pem"), "rb") as fh: + PRIVATE_KEY_BYTES = fh.read() + PKCS1_KEY_BYTES = PRIVATE_KEY_BYTES + +with open(os.path.join(DATA_DIR, "es384_publickey.pem"), "rb") as fh: + PUBLIC_KEY_BYTES = fh.read() + +with open(os.path.join(DATA_DIR, "es384_public_cert.pem"), "rb") as fh: + PUBLIC_CERT_BYTES = fh.read() + +# RSA keys used to test for type errors in EsVerifier and EsSigner. +with open(os.path.join(DATA_DIR, "privatekey.pem"), "rb") as fh: + RSA_PRIVATE_KEY_BYTES = fh.read() + RSA_PKCS1_KEY_BYTES = RSA_PRIVATE_KEY_BYTES + +with open(os.path.join(DATA_DIR, "privatekey.pub"), "rb") as fh: + RSA_PUBLIC_KEY_BYTES = fh.read() + +SERVICE_ACCOUNT_JSON_FILE = os.path.join(DATA_DIR, "es384_service_account.json") + +with open(SERVICE_ACCOUNT_JSON_FILE, "rb") as fh: + SERVICE_ACCOUNT_INFO = json.load(fh) + + +class TestEsVerifier(object): + def test_verify_success(self): + to_sign = b"foo" + signer = es.EsSigner.from_string(PRIVATE_KEY_BYTES) + actual_signature = signer.sign(to_sign) + + verifier = es.EsVerifier.from_string(PUBLIC_KEY_BYTES) + assert verifier.verify(to_sign, actual_signature) + + def test_verify_unicode_success(self): + to_sign = "foo" + signer = es.EsSigner.from_string(PRIVATE_KEY_BYTES) + actual_signature = signer.sign(to_sign) + + verifier = es.EsVerifier.from_string(PUBLIC_KEY_BYTES) + assert verifier.verify(to_sign, actual_signature) + + def test_verify_failure(self): + verifier = es.EsVerifier.from_string(PUBLIC_KEY_BYTES) + bad_signature1 = b"" + assert not verifier.verify(b"foo", bad_signature1) + bad_signature2 = b"a" + assert not verifier.verify(b"foo", bad_signature2) + + def test_verify_failure_with_wrong_raw_signature(self): + to_sign = b"foo" + + # This signature has a wrong "r" value in the "(r,s)" raw signature. + wrong_signature = base64.urlsafe_b64decode( + b"m7oaRxUDeYqjZ8qiMwo0PZLTMZWKJLFQREpqce1StMIa_yXQQ-C5WgeIRHW7OqlYSDL0XbUrj_uAw9i-QhfOJQ==" + ) + + verifier = es.EsVerifier.from_string(PUBLIC_KEY_BYTES) + assert not verifier.verify(to_sign, wrong_signature) + + def test_from_string_pub_key(self): + verifier = es.EsVerifier.from_string(PUBLIC_KEY_BYTES) + assert isinstance(verifier, es.EsVerifier) + assert isinstance(verifier._pubkey, ec.EllipticCurvePublicKey) + + def test_from_string_pub_key_unicode(self): + public_key = _helpers.from_bytes(PUBLIC_KEY_BYTES) + verifier = es.EsVerifier.from_string(public_key) + assert isinstance(verifier, es.EsVerifier) + assert isinstance(verifier._pubkey, ec.EllipticCurvePublicKey) + + def test_from_string_pub_cert(self): + verifier = es.EsVerifier.from_string(PUBLIC_CERT_BYTES) + assert isinstance(verifier, es.EsVerifier) + assert isinstance(verifier._pubkey, ec.EllipticCurvePublicKey) + + def test_from_string_pub_cert_unicode(self): + public_cert = _helpers.from_bytes(PUBLIC_CERT_BYTES) + verifier = es.EsVerifier.from_string(public_cert) + assert isinstance(verifier, es.EsVerifier) + assert isinstance(verifier._pubkey, ec.EllipticCurvePublicKey) + + def test_from_string_type_error(self): + with pytest.raises(TypeError): + es.EsVerifier.from_string(RSA_PUBLIC_KEY_BYTES) + + +class TestEsSigner(object): + def test_from_string_pkcs1(self): + signer = es.EsSigner.from_string(PKCS1_KEY_BYTES) + assert isinstance(signer, es.EsSigner) + assert isinstance(signer._key, ec.EllipticCurvePrivateKey) + + def test_from_string_pkcs1_unicode(self): + key_bytes = _helpers.from_bytes(PKCS1_KEY_BYTES) + signer = es.EsSigner.from_string(key_bytes) + assert isinstance(signer, es.EsSigner) + assert isinstance(signer._key, ec.EllipticCurvePrivateKey) + + def test_from_string_bogus_key(self): + key_bytes = "bogus-key" + with pytest.raises(ValueError): + es.EsSigner.from_string(key_bytes) + + def test_from_string_type_error(self): + key_bytes = _helpers.from_bytes(RSA_PKCS1_KEY_BYTES) + with pytest.raises(TypeError): + es.EsSigner.from_string(key_bytes) + + def test_from_service_account_info(self): + signer = es.EsSigner.from_service_account_info(SERVICE_ACCOUNT_INFO) + + assert signer.key_id == SERVICE_ACCOUNT_INFO[base._JSON_FILE_PRIVATE_KEY_ID] + assert isinstance(signer._key, ec.EllipticCurvePrivateKey) + + def test_from_service_account_info_missing_key(self): + with pytest.raises(ValueError) as excinfo: + es.EsSigner.from_service_account_info({}) + + assert excinfo.match(base._JSON_FILE_PRIVATE_KEY) + + def test_from_service_account_file(self): + signer = es.EsSigner.from_service_account_file(SERVICE_ACCOUNT_JSON_FILE) + + assert signer.key_id == SERVICE_ACCOUNT_INFO[base._JSON_FILE_PRIVATE_KEY_ID] + assert isinstance(signer._key, ec.EllipticCurvePrivateKey) + + def test_pickle(self): + signer = es.EsSigner.from_service_account_file(SERVICE_ACCOUNT_JSON_FILE) + + assert signer.key_id == SERVICE_ACCOUNT_INFO[base._JSON_FILE_PRIVATE_KEY_ID] + assert isinstance(signer._key, ec.EllipticCurvePrivateKey) + + pickled_signer = pickle.dumps(signer) + signer = pickle.loads(pickled_signer) + + assert signer.key_id == SERVICE_ACCOUNT_INFO[base._JSON_FILE_PRIVATE_KEY_ID] + assert isinstance(signer._key, ec.EllipticCurvePrivateKey) diff --git a/packages/google-auth/tests/crypt/test_es256.py b/packages/google-auth/tests/crypt/test_es256.py new file mode 100644 index 000000000000..fa5070f89d55 --- /dev/null +++ b/packages/google-auth/tests/crypt/test_es256.py @@ -0,0 +1,156 @@ +# Copyright 2016 Google Inc. +# +# 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 base64 +import json +import os +import pickle + +from cryptography.hazmat.primitives.asymmetric import ec +import pytest # type: ignore + +from google.auth import _helpers +from google.auth.crypt import base +from google.auth.crypt import es256 + + +DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "data") + +# To generate es256_privatekey.pem, es256_privatekey.pub, and +# es256_public_cert.pem: +# $ openssl ecparam -genkey -name prime256v1 -noout -out es256_privatekey.pem +# $ openssl ec -in es256-private-key.pem -pubout -out es256-publickey.pem +# $ openssl req -new -x509 -key es256_privatekey.pem -out \ +# > es256_public_cert.pem + +with open(os.path.join(DATA_DIR, "es256_privatekey.pem"), "rb") as fh: + PRIVATE_KEY_BYTES = fh.read() + PKCS1_KEY_BYTES = PRIVATE_KEY_BYTES + +with open(os.path.join(DATA_DIR, "es256_publickey.pem"), "rb") as fh: + PUBLIC_KEY_BYTES = fh.read() + +with open(os.path.join(DATA_DIR, "es256_public_cert.pem"), "rb") as fh: + PUBLIC_CERT_BYTES = fh.read() + +SERVICE_ACCOUNT_JSON_FILE = os.path.join(DATA_DIR, "es256_service_account.json") + +with open(SERVICE_ACCOUNT_JSON_FILE, "rb") as fh: + SERVICE_ACCOUNT_INFO = json.load(fh) + + +class TestES256Verifier(object): + def test_verify_success(self): + to_sign = b"foo" + signer = es256.ES256Signer.from_string(PRIVATE_KEY_BYTES) + actual_signature = signer.sign(to_sign) + + verifier = es256.ES256Verifier.from_string(PUBLIC_KEY_BYTES) + assert verifier.verify(to_sign, actual_signature) + + def test_verify_unicode_success(self): + to_sign = "foo" + signer = es256.ES256Signer.from_string(PRIVATE_KEY_BYTES) + actual_signature = signer.sign(to_sign) + + verifier = es256.ES256Verifier.from_string(PUBLIC_KEY_BYTES) + assert verifier.verify(to_sign, actual_signature) + + def test_verify_failure(self): + verifier = es256.ES256Verifier.from_string(PUBLIC_KEY_BYTES) + bad_signature1 = b"" + assert not verifier.verify(b"foo", bad_signature1) + bad_signature2 = b"a" + assert not verifier.verify(b"foo", bad_signature2) + + def test_verify_failure_with_wrong_raw_signature(self): + to_sign = b"foo" + + # This signature has a wrong "r" value in the "(r,s)" raw signature. + wrong_signature = base64.urlsafe_b64decode( + b"m7oaRxUDeYqjZ8qiMwo0PZLTMZWKJLFQREpqce1StMIa_yXQQ-C5WgeIRHW7OqlYSDL0XbUrj_uAw9i-QhfOJQ==" + ) + + verifier = es256.ES256Verifier.from_string(PUBLIC_KEY_BYTES) + assert not verifier.verify(to_sign, wrong_signature) + + def test_from_string_pub_key(self): + verifier = es256.ES256Verifier.from_string(PUBLIC_KEY_BYTES) + assert isinstance(verifier, es256.ES256Verifier) + assert isinstance(verifier._pubkey, ec.EllipticCurvePublicKey) + + def test_from_string_pub_key_unicode(self): + public_key = _helpers.from_bytes(PUBLIC_KEY_BYTES) + verifier = es256.ES256Verifier.from_string(public_key) + assert isinstance(verifier, es256.ES256Verifier) + assert isinstance(verifier._pubkey, ec.EllipticCurvePublicKey) + + def test_from_string_pub_cert(self): + verifier = es256.ES256Verifier.from_string(PUBLIC_CERT_BYTES) + assert isinstance(verifier, es256.ES256Verifier) + assert isinstance(verifier._pubkey, ec.EllipticCurvePublicKey) + + def test_from_string_pub_cert_unicode(self): + public_cert = _helpers.from_bytes(PUBLIC_CERT_BYTES) + verifier = es256.ES256Verifier.from_string(public_cert) + assert isinstance(verifier, es256.ES256Verifier) + assert isinstance(verifier._pubkey, ec.EllipticCurvePublicKey) + + +class TestES256Signer(object): + def test_from_string_pkcs1(self): + signer = es256.ES256Signer.from_string(PKCS1_KEY_BYTES) + assert isinstance(signer, es256.ES256Signer) + assert isinstance(signer._key, ec.EllipticCurvePrivateKey) + + def test_from_string_pkcs1_unicode(self): + key_bytes = _helpers.from_bytes(PKCS1_KEY_BYTES) + signer = es256.ES256Signer.from_string(key_bytes) + assert isinstance(signer, es256.ES256Signer) + assert isinstance(signer._key, ec.EllipticCurvePrivateKey) + + def test_from_string_bogus_key(self): + key_bytes = "bogus-key" + with pytest.raises(ValueError): + es256.ES256Signer.from_string(key_bytes) + + def test_from_service_account_info(self): + signer = es256.ES256Signer.from_service_account_info(SERVICE_ACCOUNT_INFO) + + assert signer.key_id == SERVICE_ACCOUNT_INFO[base._JSON_FILE_PRIVATE_KEY_ID] + assert isinstance(signer._key, ec.EllipticCurvePrivateKey) + + def test_from_service_account_info_missing_key(self): + with pytest.raises(ValueError) as excinfo: + es256.ES256Signer.from_service_account_info({}) + + assert excinfo.match(base._JSON_FILE_PRIVATE_KEY) + + def test_from_service_account_file(self): + signer = es256.ES256Signer.from_service_account_file(SERVICE_ACCOUNT_JSON_FILE) + + assert signer.key_id == SERVICE_ACCOUNT_INFO[base._JSON_FILE_PRIVATE_KEY_ID] + assert isinstance(signer._key, ec.EllipticCurvePrivateKey) + + def test_pickle(self): + signer = es256.ES256Signer.from_service_account_file(SERVICE_ACCOUNT_JSON_FILE) + + assert signer.key_id == SERVICE_ACCOUNT_INFO[base._JSON_FILE_PRIVATE_KEY_ID] + assert isinstance(signer._key, ec.EllipticCurvePrivateKey) + + pickled_signer = pickle.dumps(signer) + signer = pickle.loads(pickled_signer) + + assert signer.key_id == SERVICE_ACCOUNT_INFO[base._JSON_FILE_PRIVATE_KEY_ID] + assert isinstance(signer._key, ec.EllipticCurvePrivateKey) diff --git a/packages/google-auth/tests/data/authorized_user.json b/packages/google-auth/tests/data/authorized_user.json new file mode 100644 index 000000000000..4787acee5764 --- /dev/null +++ b/packages/google-auth/tests/data/authorized_user.json @@ -0,0 +1,6 @@ +{ + "client_id": "123", + "client_secret": "secret", + "refresh_token": "alabalaportocala", + "type": "authorized_user" +} diff --git a/packages/google-auth/tests/data/authorized_user_cloud_sdk.json b/packages/google-auth/tests/data/authorized_user_cloud_sdk.json new file mode 100644 index 000000000000..c9e19a66e01c --- /dev/null +++ b/packages/google-auth/tests/data/authorized_user_cloud_sdk.json @@ -0,0 +1,6 @@ +{ + "client_id": "764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com", + "client_secret": "secret", + "refresh_token": "alabalaportocala", + "type": "authorized_user" +} diff --git a/packages/google-auth/tests/data/authorized_user_cloud_sdk_with_quota_project_id.json b/packages/google-auth/tests/data/authorized_user_cloud_sdk_with_quota_project_id.json new file mode 100644 index 000000000000..53a8ff88aa28 --- /dev/null +++ b/packages/google-auth/tests/data/authorized_user_cloud_sdk_with_quota_project_id.json @@ -0,0 +1,7 @@ +{ + "client_id": "764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com", + "client_secret": "secret", + "refresh_token": "alabalaportocala", + "type": "authorized_user", + "quota_project_id": "quota_project_id" +} diff --git a/packages/google-auth/tests/data/authorized_user_with_rapt_token.json b/packages/google-auth/tests/data/authorized_user_with_rapt_token.json new file mode 100644 index 000000000000..64b161d42268 --- /dev/null +++ b/packages/google-auth/tests/data/authorized_user_with_rapt_token.json @@ -0,0 +1,8 @@ +{ + "client_id": "123", + "client_secret": "secret", + "refresh_token": "alabalaportocala", + "type": "authorized_user", + "rapt_token": "rapt" + } + \ No newline at end of file diff --git a/packages/google-auth/tests/data/client_secrets.json b/packages/google-auth/tests/data/client_secrets.json new file mode 100644 index 000000000000..1baa4995aff5 --- /dev/null +++ b/packages/google-auth/tests/data/client_secrets.json @@ -0,0 +1,14 @@ +{ + "web": { + "client_id": "example.apps.googleusercontent.com", + "project_id": "example", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://accounts.google.com/o/oauth2/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_secret": "itsasecrettoeveryone", + "redirect_uris": [ + "urn:ietf:wg:oauth:2.0:oob", + "http://localhost" + ] + } +} diff --git a/packages/google-auth/tests/data/context_aware_metadata.json b/packages/google-auth/tests/data/context_aware_metadata.json new file mode 100644 index 000000000000..ec40e783f1be --- /dev/null +++ b/packages/google-auth/tests/data/context_aware_metadata.json @@ -0,0 +1,6 @@ +{ + "cert_provider_command":[ + "/opt/google/endpoint-verification/bin/SecureConnectHelper", + "--print_certificate"], + "device_resource_ids":["11111111-1111-1111"] +} diff --git a/packages/google-auth/tests/data/enterprise_cert_invalid.json b/packages/google-auth/tests/data/enterprise_cert_invalid.json new file mode 100644 index 000000000000..4715a590a1ee --- /dev/null +++ b/packages/google-auth/tests/data/enterprise_cert_invalid.json @@ -0,0 +1,3 @@ +{ + "libs": {} +} \ No newline at end of file diff --git a/packages/google-auth/tests/data/enterprise_cert_valid.json b/packages/google-auth/tests/data/enterprise_cert_valid.json new file mode 100644 index 000000000000..e445f55f8a5d --- /dev/null +++ b/packages/google-auth/tests/data/enterprise_cert_valid.json @@ -0,0 +1,6 @@ +{ + "libs": { + "ecp_client": "/path/to/signer/lib", + "tls_offload": "/path/to/offload/lib" + } +} diff --git a/packages/google-auth/tests/data/enterprise_cert_valid_provider.json b/packages/google-auth/tests/data/enterprise_cert_valid_provider.json new file mode 100644 index 000000000000..9b7adf8bc30d --- /dev/null +++ b/packages/google-auth/tests/data/enterprise_cert_valid_provider.json @@ -0,0 +1,6 @@ +{ + "libs": { + "ecp_client": "/path/to/signer/lib", + "ecp_provider": "/path/to/provider/lib" + } +} diff --git a/packages/google-auth/tests/data/es256_privatekey.pem b/packages/google-auth/tests/data/es256_privatekey.pem new file mode 100644 index 000000000000..5c950b514f3f --- /dev/null +++ b/packages/google-auth/tests/data/es256_privatekey.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIAIC57aTx5ev4T2HBMQk4fXV09AzLDQ3Ju1uNoEB0LngoAoGCCqGSM49 +AwEHoUQDQgAEsACsrmP6Bp216OCFm73C8W/VRHZWcO8yU/bMwx96f05BkTII3KeJ +z2O0IRAnXfso8K6YsjMuUDGCfj+b1IDIoA== +-----END EC PRIVATE KEY----- diff --git a/packages/google-auth/tests/data/es256_public_cert.pem b/packages/google-auth/tests/data/es256_public_cert.pem new file mode 100644 index 000000000000..774ca1484382 --- /dev/null +++ b/packages/google-auth/tests/data/es256_public_cert.pem @@ -0,0 +1,8 @@ +-----BEGIN CERTIFICATE----- +MIIBGDCBwAIJAPUA0H4EQWsdMAoGCCqGSM49BAMCMBUxEzARBgNVBAMMCnVuaXQt +dGVzdHMwHhcNMTkwNTA5MDI1MDExWhcNMTkwNjA4MDI1MDExWjAVMRMwEQYDVQQD +DAp1bml0LXRlc3RzMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEsACsrmP6Bp21 +6OCFm73C8W/VRHZWcO8yU/bMwx96f05BkTII3KeJz2O0IRAnXfso8K6YsjMuUDGC +fj+b1IDIoDAKBggqhkjOPQQDAgNHADBEAh8PcDTMyWk8SHqV/v8FLuMbDxdtAsq2 +dwCpuHQwqCcmAiEAnwtkiyieN+8zozaf1P4QKp2mAqNGqua50y3ua5uVotc= +-----END CERTIFICATE----- diff --git a/packages/google-auth/tests/data/es256_publickey.pem b/packages/google-auth/tests/data/es256_publickey.pem new file mode 100644 index 000000000000..51f2a03fa4a7 --- /dev/null +++ b/packages/google-auth/tests/data/es256_publickey.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEsACsrmP6Bp216OCFm73C8W/VRHZW +cO8yU/bMwx96f05BkTII3KeJz2O0IRAnXfso8K6YsjMuUDGCfj+b1IDIoA== +-----END PUBLIC KEY----- diff --git a/packages/google-auth/tests/data/es256_service_account.json b/packages/google-auth/tests/data/es256_service_account.json new file mode 100644 index 000000000000..dd26719f6215 --- /dev/null +++ b/packages/google-auth/tests/data/es256_service_account.json @@ -0,0 +1,10 @@ +{ + "type": "service_account", + "project_id": "example-project", + "private_key_id": "1", + "private_key": "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIAIC57aTx5ev4T2HBMQk4fXV09AzLDQ3Ju1uNoEB0LngoAoGCCqGSM49\nAwEHoUQDQgAEsACsrmP6Bp216OCFm73C8W/VRHZWcO8yU/bMwx96f05BkTII3KeJ\nz2O0IRAnXfso8K6YsjMuUDGCfj+b1IDIoA==\n-----END EC PRIVATE KEY-----", + "client_email": "service-account@example.com", + "client_id": "1234", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://accounts.google.com/o/oauth2/token" +} diff --git a/packages/google-auth/tests/data/es384_privatekey.pem b/packages/google-auth/tests/data/es384_privatekey.pem new file mode 100644 index 000000000000..12ff96291b89 --- /dev/null +++ b/packages/google-auth/tests/data/es384_privatekey.pem @@ -0,0 +1,6 @@ +-----BEGIN EC PRIVATE KEY----- +MIGkAgEBBDBz1wKJNXd2Rzy52A7F3f9LmLp6KaMUTbL1IT3JaDx1kOp4CUFpI9Zs +rdEx7b7kKQGgBwYFK4EEACKhZANiAATRLiEHuOwLr8bjJnJdYG2mrlWtMEPBHOrm +n7RukR80nV5uAcqt+M319T2togP0tQIe621FUpJq7+Hq0vJJbtI1MPuFSDtpZG04 +5se7BVAw63IPV1EdO6vGXxd5Fay88uU= +-----END EC PRIVATE KEY----- diff --git a/packages/google-auth/tests/data/es384_public_cert.pem b/packages/google-auth/tests/data/es384_public_cert.pem new file mode 100644 index 000000000000..e8d5d4c6851d --- /dev/null +++ b/packages/google-auth/tests/data/es384_public_cert.pem @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE----- +MIICYzCCAeqgAwIBAgIUeYyowQBkomEoMj72pNh754QlGvAwCgYIKoZIzj0EAwIw +aTELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBW +aWV3MQ8wDQYDVQQKDAZHb29nbGUxDzANBgNVBAsMBkdvb2dsZTETMBEGA1UEAwwK +Z29vZ2xlLmNvbTAeFw0yNTExMTEwMDQzMTlaFw0yNTEyMTEwMDQzMTlaMGkxCzAJ +BgNVBAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEP +MA0GA1UECgwGR29vZ2xlMQ8wDQYDVQQLDAZHb29nbGUxEzARBgNVBAMMCmdvb2ds +ZS5jb20wdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATRLiEHuOwLr8bjJnJdYG2mrlWt +MEPBHOrmn7RukR80nV5uAcqt+M319T2togP0tQIe621FUpJq7+Hq0vJJbtI1MPuF +SDtpZG045se7BVAw63IPV1EdO6vGXxd5Fay88uWjUzBRMB0GA1UdDgQWBBSRZkxR +63/X4JotxKDRWCI4PwIElDAfBgNVHSMEGDAWgBSRZkxR63/X4JotxKDRWCI4PwIE +lDAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA2cAMGQCMAU+2yy/luLTa+T6 +Jm86i9GiH/lPYdYwZFvwKJFTdj8FJpv7ySN0J80qzWxtBZTCMQIwZO0ZRdv8s7V3 +022yISIujmsPmgj7lvPuDZZaVn1DVYMG3YmBB+cTp+JTqF3x7lN+ +-----END CERTIFICATE----- diff --git a/packages/google-auth/tests/data/es384_publickey.pem b/packages/google-auth/tests/data/es384_publickey.pem new file mode 100644 index 000000000000..e78ac0f494fc --- /dev/null +++ b/packages/google-auth/tests/data/es384_publickey.pem @@ -0,0 +1,5 @@ +-----BEGIN PUBLIC KEY----- +MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE0S4hB7jsC6/G4yZyXWBtpq5VrTBDwRzq +5p+0bpEfNJ1ebgHKrfjN9fU9raID9LUCHuttRVKSau/h6tLySW7SNTD7hUg7aWRt +OObHuwVQMOtyD1dRHTurxl8XeRWsvPLl +-----END PUBLIC KEY----- diff --git a/packages/google-auth/tests/data/es384_service_account.json b/packages/google-auth/tests/data/es384_service_account.json new file mode 100644 index 000000000000..8302344b1a7c --- /dev/null +++ b/packages/google-auth/tests/data/es384_service_account.json @@ -0,0 +1,9 @@ +{ + "type":"gdch_service_account", + "format_version":"1", + "project":"mytest", + "private_key_id":"1234567890", + "private_key":"-----BEGIN EC PRIVATE KEY-----\nMIGkAgEBBDAyqgUeNwuUOMCC9Bzyf4uT2rfZyISJFMq3ByfE+ytUbveUd6RtvoCT\nS9cYbmuj06OgBwYFK4EEACKhZANiAATrUB670cjyRUcarD//92jO52Rqo+jKi0x7\nkscWALlC8bx9zED5zpy948FrQhQgb/TLPhunkyTwWe22CzafS8ik5pCZKkWfiJRV\n9IBMJDTMyocCR013qDXKHZOpJ57wAUw=\n-----END EC PRIVATE KEY-----\n", + "name":"mytest", + "token_uri":"https://service-accounts.org.google.com/authenticate" +} diff --git a/packages/google-auth/tests/data/external_account_authorized_user.json b/packages/google-auth/tests/data/external_account_authorized_user.json new file mode 100644 index 000000000000..e0bd20c8fd5c --- /dev/null +++ b/packages/google-auth/tests/data/external_account_authorized_user.json @@ -0,0 +1,9 @@ +{ + "type": "external_account_authorized_user", + "audience": "//iam.googleapis.com/locations/global/workforcePools/$WORKFORCE_POOL_ID/providers/$PROVIDER_ID", + "refresh_token": "refreshToken", + "token_url": "https://sts.googleapis.com/v1/oauth/token", + "token_info_url": "https://sts.googleapis.com/v1/instrospect", + "client_id": "clientId", + "client_secret": "clientSecret" +} diff --git a/packages/google-auth/tests/data/external_account_authorized_user_non_gdu.json b/packages/google-auth/tests/data/external_account_authorized_user_non_gdu.json new file mode 100644 index 000000000000..b82854c74377 --- /dev/null +++ b/packages/google-auth/tests/data/external_account_authorized_user_non_gdu.json @@ -0,0 +1,10 @@ +{ + "type": "external_account_authorized_user", + "audience": "//iam.fake_universe_domain/locations/global/workforcePools/$WORKFORCE_POOL_ID/providers/$PROVIDER_ID", + "refresh_token": "refreshToken", + "token_url": "https://sts.fake_universe_domain/v1/oauth/token", + "token_info_url": "https://sts.fake_universe_domain/v1/instrospect", + "client_id": "clientId", + "client_secret": "clientSecret", + "universe_domain": "fake_universe_domain" +} diff --git a/packages/google-auth/tests/data/external_subject_token.json b/packages/google-auth/tests/data/external_subject_token.json new file mode 100644 index 000000000000..a47ec341270c --- /dev/null +++ b/packages/google-auth/tests/data/external_subject_token.json @@ -0,0 +1,3 @@ +{ + "access_token": "HEADER.SIMULATED_JWT_PAYLOAD.SIGNATURE" +} \ No newline at end of file diff --git a/packages/google-auth/tests/data/external_subject_token.txt b/packages/google-auth/tests/data/external_subject_token.txt new file mode 100644 index 000000000000..c668d8f71d3b --- /dev/null +++ b/packages/google-auth/tests/data/external_subject_token.txt @@ -0,0 +1 @@ +HEADER.SIMULATED_JWT_PAYLOAD.SIGNATURE \ No newline at end of file diff --git a/packages/google-auth/tests/data/gdch_service_account.json b/packages/google-auth/tests/data/gdch_service_account.json new file mode 100644 index 000000000000..172164e9fa17 --- /dev/null +++ b/packages/google-auth/tests/data/gdch_service_account.json @@ -0,0 +1,11 @@ +{ + "type": "gdch_service_account", + "format_version": "1", + "project": "project_foo", + "private_key_id": "key_foo", + "private_key": "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIIGb2np7v54Hs6++NiLE7CQtQg7rzm4znstHvrOUlcMMoAoGCCqGSM49\nAwEHoUQDQgAECvv0VyZS9nYOa8tdwKCbkNxlWgrAZVClhJXqrvOZHlH4N3d8Rplk\n2DEJvzp04eMxlHw1jm6JCs3iJR6KAokG+w==\n-----END EC PRIVATE KEY-----\n", + "name": "service_identity_name", + "ca_cert_path": "/path/to/ca/cert", + "token_uri": "https://service-identity./authenticate" +} + diff --git a/packages/google-auth/tests/data/impersonated_service_account_authorized_user_source.json b/packages/google-auth/tests/data/impersonated_service_account_authorized_user_source.json new file mode 100644 index 000000000000..0e545392ccff --- /dev/null +++ b/packages/google-auth/tests/data/impersonated_service_account_authorized_user_source.json @@ -0,0 +1,13 @@ +{ + "delegates": [ + "service-account-delegate@example.com" + ], + "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service-account-target@example.com:generateAccessToken", + "source_credentials": { + "client_id": "123", + "client_secret": "secret", + "refresh_token": "alabalaportocala", + "type": "authorized_user" + }, + "type": "impersonated_service_account" +} \ No newline at end of file diff --git a/packages/google-auth/tests/data/impersonated_service_account_external_account_authorized_user_source.json b/packages/google-auth/tests/data/impersonated_service_account_external_account_authorized_user_source.json new file mode 100644 index 000000000000..0bc44c13a28b --- /dev/null +++ b/packages/google-auth/tests/data/impersonated_service_account_external_account_authorized_user_source.json @@ -0,0 +1,16 @@ +{ + "delegates": [ + "service-account-delegate@example.com" + ], + "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service-account-target@example.com:generateAccessToken", + "source_credentials": { + "type": "external_account_authorized_user", + "audience": "//iam.googleapis.com/locations/global/workforcePools/$WORKFORCE_POOL_ID/providers/$PROVIDER_ID", + "refresh_token": "refreshToken", + "token_url": "https://sts.googleapis.com/v1/oauth/token", + "token_info_url": "https://sts.googleapis.com/v1/instrospect", + "client_id": "clientId", + "client_secret": "clientSecret" + }, + "type": "impersonated_service_account" +} \ No newline at end of file diff --git a/packages/google-auth/tests/data/impersonated_service_account_service_account_source.json b/packages/google-auth/tests/data/impersonated_service_account_service_account_source.json new file mode 100644 index 000000000000..e1ff8e81f7c1 --- /dev/null +++ b/packages/google-auth/tests/data/impersonated_service_account_service_account_source.json @@ -0,0 +1,17 @@ +{ + "delegates": [ + "service-account-delegate@example.com" + ], + "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service-account-target@example.com:generateAccessToken", + "source_credentials": { + "type": "service_account", + "project_id": "example-project", + "private_key_id": "1", + "private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA4ej0p7bQ7L/r4rVGUz9RN4VQWoej1Bg1mYWIDYslvKrk1gpj\n7wZgkdmM7oVK2OfgrSj/FCTkInKPqaCR0gD7K80q+mLBrN3PUkDrJQZpvRZIff3/\nxmVU1WeruQLFJjnFb2dqu0s/FY/2kWiJtBCakXvXEOb7zfbINuayL+MSsCGSdVYs\nSliS5qQpgyDap+8b5fpXZVJkq92hrcNtbkg7hCYUJczt8n9hcCTJCfUpApvaFQ18\npe+zpyl4+WzkP66I28hniMQyUlA1hBiskT7qiouq0m8IOodhv2fagSZKjOTTU2xk\nSBc//fy3ZpsL7WqgsZS7Q+0VRK8gKfqkxg5OYQIDAQABAoIBAQDGGHzQxGKX+ANk\nnQi53v/c6632dJKYXVJC+PDAz4+bzU800Y+n/bOYsWf/kCp94XcG4Lgsdd0Gx+Zq\nHD9CI1IcqqBRR2AFscsmmX6YzPLTuEKBGMW8twaYy3utlFxElMwoUEsrSWRcCA1y\nnHSDzTt871c7nxCXHxuZ6Nm/XCL7Bg8uidRTSC1sQrQyKgTPhtQdYrPQ4WZ1A4J9\nIisyDYmZodSNZe5P+LTJ6M1SCgH8KH9ZGIxv3diMwzNNpk3kxJc9yCnja4mjiGE2\nYCNusSycU5IhZwVeCTlhQGcNeV/skfg64xkiJE34c2y2ttFbdwBTPixStGaF09nU\nZ422D40BAoGBAPvVyRRsC3BF+qZdaSMFwI1yiXY7vQw5+JZh01tD28NuYdRFzjcJ\nvzT2n8LFpj5ZfZFvSMLMVEFVMgQvWnN0O6xdXvGov6qlRUSGaH9u+TCPNnIldjMP\nB8+xTwFMqI7uQr54wBB+Poq7dVRP+0oHb0NYAwUBXoEuvYo3c/nDoRcZAoGBAOWl\naLHjMv4CJbArzT8sPfic/8waSiLV9Ixs3Re5YREUTtnLq7LoymqB57UXJB3BNz/2\neCueuW71avlWlRtE/wXASj5jx6y5mIrlV4nZbVuyYff0QlcG+fgb6pcJQuO9DxMI\naqFGrWP3zye+LK87a6iR76dS9vRU+bHZpSVvGMKJAoGAFGt3TIKeQtJJyqeUWNSk\nklORNdcOMymYMIlqG+JatXQD1rR6ThgqOt8sgRyJqFCVT++YFMOAqXOBBLnaObZZ\nCFbh1fJ66BlSjoXff0W+SuOx5HuJJAa5+WtFHrPajwxeuRcNa8jwxUsB7n41wADu\nUqWWSRedVBg4Ijbw3nWwYDECgYB0pLew4z4bVuvdt+HgnJA9n0EuYowVdadpTEJg\nsoBjNHV4msLzdNqbjrAqgz6M/n8Ztg8D2PNHMNDNJPVHjJwcR7duSTA6w2p/4k28\nbvvk/45Ta3XmzlxZcZSOct3O31Cw0i2XDVc018IY5be8qendDYM08icNo7vQYkRH\n504kQQKBgQDjx60zpz8ozvm1XAj0wVhi7GwXe+5lTxiLi9Fxq721WDxPMiHDW2XL\nYXfFVy/9/GIMvEiGYdmarK1NW+VhWl1DC5xhDg0kvMfxplt4tynoq1uTsQTY31Mx\nBeF5CT/JuNYk3bEBF0H/Q3VGO1/ggVS+YezdFbLWIRoMnLj6XCFEGg==\n-----END RSA PRIVATE KEY-----\n", + "client_email": "service-account@example.com", + "client_id": "1234", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://accounts.google.com/o/oauth2/token" + }, + "type": "impersonated_service_account" +} \ No newline at end of file diff --git a/packages/google-auth/tests/data/impersonated_service_account_with_quota_project.json b/packages/google-auth/tests/data/impersonated_service_account_with_quota_project.json new file mode 100644 index 000000000000..89db9617c467 --- /dev/null +++ b/packages/google-auth/tests/data/impersonated_service_account_with_quota_project.json @@ -0,0 +1,14 @@ +{ + "delegates": [ + "service-account-delegate@example.com" + ], + "quota_project_id": "quota_project", + "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service-account-target@example.com:generateAccessToken", + "source_credentials": { + "client_id": "123", + "client_secret": "secret", + "refresh_token": "alabalaportocala", + "type": "authorized_user" + }, + "type": "impersonated_service_account" +} \ No newline at end of file diff --git a/packages/google-auth/tests/data/old_oauth_credentials_py3.pickle b/packages/google-auth/tests/data/old_oauth_credentials_py3.pickle new file mode 100644 index 000000000000..c8a05599b1b7 Binary files /dev/null and b/packages/google-auth/tests/data/old_oauth_credentials_py3.pickle differ diff --git a/packages/google-auth/tests/data/other_cert.pem b/packages/google-auth/tests/data/other_cert.pem new file mode 100644 index 000000000000..6895d1e7bfaf --- /dev/null +++ b/packages/google-auth/tests/data/other_cert.pem @@ -0,0 +1,33 @@ +-----BEGIN CERTIFICATE----- +MIIFtTCCA52gAwIBAgIJAPBsLZmNGfKtMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTYwOTIxMDI0NTEyWhcNMTYxMDIxMDI0NTEyWjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC +CgKCAgEAsiMC7mTsmUXwZoYlT4aHY1FLw8bxIXC+z3IqA+TY1WqfbeiZRo8MA5Zx +lTTxYMKPCZUE1XBc7jvD8GJhWIj6pToPYHn73B01IBkLBxq4kF1yV2Z7DVmkvc6H +EcxXXq8zkCx0j6XOfiI4+qkXnuQn8cvrk8xfhtnMMZM7iVm6VSN93iRP/8ey6xuL +XTHrDX7ukoRce1hpT8O+15GXNrY0irhhYQz5xKibNCJF3EjV28WMry8y7I8uYUFU +RWDiQawwK9ec1zhZ94v92+GZDlPevmcFmSERKYQ0NsKcT0Y3lGuGnaExs8GyOpnC +oksu4YJGXQjg7lkv4MxzsNbRqmCkUwxw1Mg6FP0tsCNsw9qTrkvWCRA9zp/aU+sZ +IBGh1t4UGCub8joeQFvHxvr/3F7mH/dyvCjA34u0Lo1VPx+jYUIi9i0odltMspDW +xOpjqdGARZYmlJP5Au9q5cQjPMcwS/EBIb8cwNl32mUE6WnFlep+38mNR/FghIjO +ViAkXuKQmcHe6xppZAoHFsO/t3l4Tjek5vNW7erI1rgrFku/fvkIW/G8V1yIm/+Q +F+CE4maQzCJfhftpkhM/sPC/FuLNBmNE8BHVX8y58xG4is/cQxL4Z9TsFIw0C5+3 +uTrFW9D0agysahMVzPGtCqhDQqJdIJrBQqlS6bztpzBA8zEI0skCAwEAAaOBpzCB +pDAdBgNVHQ4EFgQUz/8FmW6TfqXyNJZr7rhc+Tn5sKQwdQYDVR0jBG4wbIAUz/8F +mW6TfqXyNJZr7rhc+Tn5sKShSaRHMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpT +b21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGSCCQDw +bC2ZjRnyrTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4ICAQCQmrcfhurX +riR3Q0Y+nq040/3dJIAJXjyI9CEtxaU0nzCNTng7PwgZ0CKmCelQfInuwWFwBSHS +6kBfC1rgJeFnjnTt8a3RCgRlIgUr9NCdPSEccB7TurobwPJ2h6cJjjR8urcb0CXh +CEMvPneyPj0xUFY8vVKXMGWahz/kyfwIiVqcX/OtMZ29fUu1onbWl71g2gVLtUZl +sECdZ+AC/6HDCVpYIVETMl1T7N/XyqXZQiDLDNRDeZhnapz8w9fsW1KVujAZLNQR +pVnw2qa2UK1dSf2FHX+lQU5mFSYM4vtwaMlX/LgfdLZ9I796hFh619WwTVz+LO2N +vHnwBMabld3XSPuZRqlbBulDQ07Vbqdjv8DYSLA2aKI4ZkMMKuFLG/oS28V2ZYmv +/KpGEs5UgKY+P9NulYpTDwCU/6SomuQpP795wbG6sm7Hzq82r2RmB61GupNRGeqi +pXKsy69T388zBxYu6zQrosXiDl5YzaViH7tm0J7opye8dCWjjpnahki0vq2znti7 +6cWla2j8Xz1glvLz+JI/NCOMfxUInb82T7ijo80N0VJ2hzf7p2GxRZXAxAV9knLI +nM4F5TLjSd7ZhOOZ7ni/eZFueTMisWfypt2nc41whGjHMX/Zp1kPfhB4H2bLKIX/ +lSrwNr3qbGTEJX8JqpDBNVAd96XkMvDNyA== +-----END CERTIFICATE----- diff --git a/packages/google-auth/tests/data/pem_from_pkcs12.pem b/packages/google-auth/tests/data/pem_from_pkcs12.pem new file mode 100644 index 000000000000..2d77e10c1fd1 --- /dev/null +++ b/packages/google-auth/tests/data/pem_from_pkcs12.pem @@ -0,0 +1,32 @@ +Bag Attributes + friendlyName: key + localKeyID: 22 7E 04 FC 64 48 20 83 1E C1 BD E3 F5 2F 44 7D EA 99 A5 BC +Key Attributes: +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDh6PSnttDsv+vi +tUZTP1E3hVBah6PUGDWZhYgNiyW8quTWCmPvBmCR2YzuhUrY5+CtKP8UJOQico+p +oJHSAPsrzSr6YsGs3c9SQOslBmm9Fkh9/f/GZVTVZ6u5AsUmOcVvZ2q7Sz8Vj/aR +aIm0EJqRe9cQ5vvN9sg25rIv4xKwIZJ1VixKWJLmpCmDINqn7xvl+ldlUmSr3aGt +w21uSDuEJhQlzO3yf2FwJMkJ9SkCm9oVDXyl77OnKXj5bOQ/rojbyGeIxDJSUDWE +GKyRPuqKi6rSbwg6h2G/Z9qBJkqM5NNTbGRIFz/9/LdmmwvtaqCxlLtD7RVEryAp ++qTGDk5hAgMBAAECggEBAMYYfNDEYpf4A2SdCLne/9zrrfZ0kphdUkL48MDPj5vN +TzTRj6f9s5ixZ/+QKn3hdwbguCx13QbH5mocP0IjUhyqoFFHYAWxyyaZfpjM8tO4 +QoEYxby3BpjLe62UXESUzChQSytJZFwIDXKcdIPNO3zvVzufEJcfG5no2b9cIvsG +Dy6J1FNILWxCtDIqBM+G1B1is9DhZnUDgn0iKzINiZmh1I1l7k/4tMnozVIKAfwo +f1kYjG/d2IzDM02mTeTElz3IKeNriaOIYTZgI26xLJxTkiFnBV4JOWFAZw15X+yR ++DrjGSIkTfhzbLa20Vt3AFM+LFK0ZoXT2dRnjbYPjQECgYEA+9XJFGwLcEX6pl1p +IwXAjXKJdju9DDn4lmHTW0Pbw25h1EXONwm/NPafwsWmPll9kW9IwsxUQVUyBC9a +c3Q7rF1e8ai/qqVFRIZof275MI82ciV2Mw8Hz7FPAUyoju5CvnjAEH4+irt1VE/7 +SgdvQ1gDBQFegS69ijdz+cOhFxkCgYEA5aVoseMy/gIlsCvNPyw9+Jz/zBpKItX0 +jGzdF7lhERRO2cursujKaoHntRckHcE3P/Z4K565bvVq+VaVG0T/BcBKPmPHrLmY +iuVXidltW7Jh9/RCVwb5+BvqlwlC470PEwhqoUatY/fPJ74srztrqJHvp1L29FT5 +sdmlJW8YwokCgYAUa3dMgp5C0knKp5RY1KSSU5E11w4zKZgwiWob4lq1dAPWtHpO +GCo63yyBHImoUJVP75gUw4Cpc4EEudo5tlkIVuHV8nroGVKOhd9/Rb5K47Hke4kk +Brn5a0Ues9qPDF65Fw1ryPDFSwHufjXAAO5SpZZJF51UGDgiNvDedbBgMQKBgHSk +t7DjPhtW69234eCckD2fQS5ijBV1p2lMQmCygGM0dXiawvN02puOsCqDPoz+fxm2 +DwPY80cw0M0k9UeMnBxHt25JMDrDan/iTbxu++T/jlNrdebOXFlxlI5y3c7fULDS +LZcNVzTXwhjlt7yp6d0NgzTyJw2ju9BiREfnTiRBAoGBAOPHrTOnPyjO+bVcCPTB +WGLsbBd77mVPGIuL0XGrvbVYPE8yIcNbZcthd8VXL/38Ygy8SIZh2ZqsrU1b5WFa +XUMLnGEODSS8x/GmW3i3KeirW5OxBNjfUzEF4XkJP8m41iTdsQEXQf9DdUY7X+CB +VL5h7N0VstYhGgycuPpcIUQa +-----END PRIVATE KEY----- diff --git a/packages/google-auth/tests/data/privatekey.p12 b/packages/google-auth/tests/data/privatekey.p12 new file mode 100644 index 000000000000..c369ecb6e664 Binary files /dev/null and b/packages/google-auth/tests/data/privatekey.p12 differ diff --git a/packages/google-auth/tests/data/privatekey.pem b/packages/google-auth/tests/data/privatekey.pem new file mode 100644 index 000000000000..57443540ad39 --- /dev/null +++ b/packages/google-auth/tests/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----- diff --git a/packages/google-auth/tests/data/privatekey.pub b/packages/google-auth/tests/data/privatekey.pub new file mode 100644 index 000000000000..11fdaa42f02a --- /dev/null +++ b/packages/google-auth/tests/data/privatekey.pub @@ -0,0 +1,8 @@ +-----BEGIN RSA PUBLIC KEY----- +MIIBCgKCAQEA4ej0p7bQ7L/r4rVGUz9RN4VQWoej1Bg1mYWIDYslvKrk1gpj7wZg +kdmM7oVK2OfgrSj/FCTkInKPqaCR0gD7K80q+mLBrN3PUkDrJQZpvRZIff3/xmVU +1WeruQLFJjnFb2dqu0s/FY/2kWiJtBCakXvXEOb7zfbINuayL+MSsCGSdVYsSliS +5qQpgyDap+8b5fpXZVJkq92hrcNtbkg7hCYUJczt8n9hcCTJCfUpApvaFQ18pe+z +pyl4+WzkP66I28hniMQyUlA1hBiskT7qiouq0m8IOodhv2fagSZKjOTTU2xkSBc/ +/fy3ZpsL7WqgsZS7Q+0VRK8gKfqkxg5OYQIDAQAB +-----END RSA PUBLIC KEY----- diff --git a/packages/google-auth/tests/data/public_cert.pem b/packages/google-auth/tests/data/public_cert.pem new file mode 100644 index 000000000000..7af6ca3f9314 --- /dev/null +++ b/packages/google-auth/tests/data/public_cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDIzCCAgugAwIBAgIJAMfISuBQ5m+5MA0GCSqGSIb3DQEBBQUAMBUxEzARBgNV +BAMTCnVuaXQtdGVzdHMwHhcNMTExMjA2MTYyNjAyWhcNMjExMjAzMTYyNjAyWjAV +MRMwEQYDVQQDEwp1bml0LXRlc3RzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEA4ej0p7bQ7L/r4rVGUz9RN4VQWoej1Bg1mYWIDYslvKrk1gpj7wZgkdmM +7oVK2OfgrSj/FCTkInKPqaCR0gD7K80q+mLBrN3PUkDrJQZpvRZIff3/xmVU1Wer +uQLFJjnFb2dqu0s/FY/2kWiJtBCakXvXEOb7zfbINuayL+MSsCGSdVYsSliS5qQp +gyDap+8b5fpXZVJkq92hrcNtbkg7hCYUJczt8n9hcCTJCfUpApvaFQ18pe+zpyl4 ++WzkP66I28hniMQyUlA1hBiskT7qiouq0m8IOodhv2fagSZKjOTTU2xkSBc//fy3 +ZpsL7WqgsZS7Q+0VRK8gKfqkxg5OYQIDAQABo3YwdDAdBgNVHQ4EFgQU2RQ8yO+O +gN8oVW2SW7RLrfYd9jEwRQYDVR0jBD4wPIAU2RQ8yO+OgN8oVW2SW7RLrfYd9jGh +GaQXMBUxEzARBgNVBAMTCnVuaXQtdGVzdHOCCQDHyErgUOZvuTAMBgNVHRMEBTAD +AQH/MA0GCSqGSIb3DQEBBQUAA4IBAQBRv+M/6+FiVu7KXNjFI5pSN17OcW5QUtPr +odJMlWrJBtynn/TA1oJlYu3yV5clc/71Vr/AxuX5xGP+IXL32YDF9lTUJXG/uUGk ++JETpKmQviPbRsvzYhz4pf6ZIOZMc3/GIcNq92ECbseGO+yAgyWUVKMmZM0HqXC9 +ovNslqe0M8C1sLm1zAR5z/h/litE7/8O2ietija3Q/qtl2TOXJdCA6sgjJX2WUql +ybrC55ct18NKf3qhpcEkGQvFU40rVYApJpi98DiZPYFdx1oBDp/f4uZ3ojpxRVFT +cDwcJLfNRCPUhormsY7fDS9xSyThiHsW9mjJYdcaKQkwYZ0F11yB +-----END CERTIFICATE----- diff --git a/packages/google-auth/tests/data/service_account.json b/packages/google-auth/tests/data/service_account.json new file mode 100644 index 000000000000..9e76f4d35581 --- /dev/null +++ b/packages/google-auth/tests/data/service_account.json @@ -0,0 +1,10 @@ +{ + "type": "service_account", + "project_id": "example-project", + "private_key_id": "1", + "private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA4ej0p7bQ7L/r4rVGUz9RN4VQWoej1Bg1mYWIDYslvKrk1gpj\n7wZgkdmM7oVK2OfgrSj/FCTkInKPqaCR0gD7K80q+mLBrN3PUkDrJQZpvRZIff3/\nxmVU1WeruQLFJjnFb2dqu0s/FY/2kWiJtBCakXvXEOb7zfbINuayL+MSsCGSdVYs\nSliS5qQpgyDap+8b5fpXZVJkq92hrcNtbkg7hCYUJczt8n9hcCTJCfUpApvaFQ18\npe+zpyl4+WzkP66I28hniMQyUlA1hBiskT7qiouq0m8IOodhv2fagSZKjOTTU2xk\nSBc//fy3ZpsL7WqgsZS7Q+0VRK8gKfqkxg5OYQIDAQABAoIBAQDGGHzQxGKX+ANk\nnQi53v/c6632dJKYXVJC+PDAz4+bzU800Y+n/bOYsWf/kCp94XcG4Lgsdd0Gx+Zq\nHD9CI1IcqqBRR2AFscsmmX6YzPLTuEKBGMW8twaYy3utlFxElMwoUEsrSWRcCA1y\nnHSDzTt871c7nxCXHxuZ6Nm/XCL7Bg8uidRTSC1sQrQyKgTPhtQdYrPQ4WZ1A4J9\nIisyDYmZodSNZe5P+LTJ6M1SCgH8KH9ZGIxv3diMwzNNpk3kxJc9yCnja4mjiGE2\nYCNusSycU5IhZwVeCTlhQGcNeV/skfg64xkiJE34c2y2ttFbdwBTPixStGaF09nU\nZ422D40BAoGBAPvVyRRsC3BF+qZdaSMFwI1yiXY7vQw5+JZh01tD28NuYdRFzjcJ\nvzT2n8LFpj5ZfZFvSMLMVEFVMgQvWnN0O6xdXvGov6qlRUSGaH9u+TCPNnIldjMP\nB8+xTwFMqI7uQr54wBB+Poq7dVRP+0oHb0NYAwUBXoEuvYo3c/nDoRcZAoGBAOWl\naLHjMv4CJbArzT8sPfic/8waSiLV9Ixs3Re5YREUTtnLq7LoymqB57UXJB3BNz/2\neCueuW71avlWlRtE/wXASj5jx6y5mIrlV4nZbVuyYff0QlcG+fgb6pcJQuO9DxMI\naqFGrWP3zye+LK87a6iR76dS9vRU+bHZpSVvGMKJAoGAFGt3TIKeQtJJyqeUWNSk\nklORNdcOMymYMIlqG+JatXQD1rR6ThgqOt8sgRyJqFCVT++YFMOAqXOBBLnaObZZ\nCFbh1fJ66BlSjoXff0W+SuOx5HuJJAa5+WtFHrPajwxeuRcNa8jwxUsB7n41wADu\nUqWWSRedVBg4Ijbw3nWwYDECgYB0pLew4z4bVuvdt+HgnJA9n0EuYowVdadpTEJg\nsoBjNHV4msLzdNqbjrAqgz6M/n8Ztg8D2PNHMNDNJPVHjJwcR7duSTA6w2p/4k28\nbvvk/45Ta3XmzlxZcZSOct3O31Cw0i2XDVc018IY5be8qendDYM08icNo7vQYkRH\n504kQQKBgQDjx60zpz8ozvm1XAj0wVhi7GwXe+5lTxiLi9Fxq721WDxPMiHDW2XL\nYXfFVy/9/GIMvEiGYdmarK1NW+VhWl1DC5xhDg0kvMfxplt4tynoq1uTsQTY31Mx\nBeF5CT/JuNYk3bEBF0H/Q3VGO1/ggVS+YezdFbLWIRoMnLj6XCFEGg==\n-----END RSA PRIVATE KEY-----\n", + "client_email": "service-account@example.com", + "client_id": "1234", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://accounts.google.com/o/oauth2/token" +} diff --git a/packages/google-auth/tests/data/service_account_non_gdu.json b/packages/google-auth/tests/data/service_account_non_gdu.json new file mode 100644 index 000000000000..976184f8c249 --- /dev/null +++ b/packages/google-auth/tests/data/service_account_non_gdu.json @@ -0,0 +1,15 @@ +{ + "type": "service_account", + "universe_domain": "universe.foo", + "project_id": "example_project", + "private_key_id": "1", + "private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA4ej0p7bQ7L/r4rVGUz9RN4VQWoej1Bg1mYWIDYslvKrk1gpj\n7wZgkdmM7oVK2OfgrSj/FCTkInKPqaCR0gD7K80q+mLBrN3PUkDrJQZpvRZIff3/\nxmVU1WeruQLFJjnFb2dqu0s/FY/2kWiJtBCakXvXEOb7zfbINuayL+MSsCGSdVYs\nSliS5qQpgyDap+8b5fpXZVJkq92hrcNtbkg7hCYUJczt8n9hcCTJCfUpApvaFQ18\npe+zpyl4+WzkP66I28hniMQyUlA1hBiskT7qiouq0m8IOodhv2fagSZKjOTTU2xk\nSBc//fy3ZpsL7WqgsZS7Q+0VRK8gKfqkxg5OYQIDAQABAoIBAQDGGHzQxGKX+ANk\nnQi53v/c6632dJKYXVJC+PDAz4+bzU800Y+n/bOYsWf/kCp94XcG4Lgsdd0Gx+Zq\nHD9CI1IcqqBRR2AFscsmmX6YzPLTuEKBGMW8twaYy3utlFxElMwoUEsrSWRcCA1y\nnHSDzTt871c7nxCXHxuZ6Nm/XCL7Bg8uidRTSC1sQrQyKgTPhtQdYrPQ4WZ1A4J9\nIisyDYmZodSNZe5P+LTJ6M1SCgH8KH9ZGIxv3diMwzNNpk3kxJc9yCnja4mjiGE2\nYCNusSycU5IhZwVeCTlhQGcNeV/skfg64xkiJE34c2y2ttFbdwBTPixStGaF09nU\nZ422D40BAoGBAPvVyRRsC3BF+qZdaSMFwI1yiXY7vQw5+JZh01tD28NuYdRFzjcJ\nvzT2n8LFpj5ZfZFvSMLMVEFVMgQvWnN0O6xdXvGov6qlRUSGaH9u+TCPNnIldjMP\nB8+xTwFMqI7uQr54wBB+Poq7dVRP+0oHb0NYAwUBXoEuvYo3c/nDoRcZAoGBAOWl\naLHjMv4CJbArzT8sPfic/8waSiLV9Ixs3Re5YREUTtnLq7LoymqB57UXJB3BNz/2\neCueuW71avlWlRtE/wXASj5jx6y5mIrlV4nZbVuyYff0QlcG+fgb6pcJQuO9DxMI\naqFGrWP3zye+LK87a6iR76dS9vRU+bHZpSVvGMKJAoGAFGt3TIKeQtJJyqeUWNSk\nklORNdcOMymYMIlqG+JatXQD1rR6ThgqOt8sgRyJqFCVT++YFMOAqXOBBLnaObZZ\nCFbh1fJ66BlSjoXff0W+SuOx5HuJJAa5+WtFHrPajwxeuRcNa8jwxUsB7n41wADu\nUqWWSRedVBg4Ijbw3nWwYDECgYB0pLew4z4bVuvdt+HgnJA9n0EuYowVdadpTEJg\nsoBjNHV4msLzdNqbjrAqgz6M/n8Ztg8D2PNHMNDNJPVHjJwcR7duSTA6w2p/4k28\nbvvk/45Ta3XmzlxZcZSOct3O31Cw0i2XDVc018IY5be8qendDYM08icNo7vQYkRH\n504kQQKBgQDjx60zpz8ozvm1XAj0wVhi7GwXe+5lTxiLi9Fxq721WDxPMiHDW2XL\nYXfFVy/9/GIMvEiGYdmarK1NW+VhWl1DC5xhDg0kvMfxplt4tynoq1uTsQTY31Mx\nBeF5CT/JuNYk3bEBF0H/Q3VGO1/ggVS+YezdFbLWIRoMnLj6XCFEGg==\n-----END RSA PRIVATE KEY-----\n", + "client_email": "testsa@foo.iam.gserviceaccount.com", + "client_id": "1234", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.universe.foo/token", + "auth_provider_x509_cert_url": "https://www.universe.foo/oauth2/v1/certs", + "client_x509_cert_url": "https://www.universe.foo/robot/v1/metadata/x509/foo.iam.gserviceaccount.com" +} + + \ No newline at end of file diff --git a/packages/google-auth/tests/data/trust_chain_with_leaf.pem b/packages/google-auth/tests/data/trust_chain_with_leaf.pem new file mode 100644 index 000000000000..250387d9d59c --- /dev/null +++ b/packages/google-auth/tests/data/trust_chain_with_leaf.pem @@ -0,0 +1,52 @@ +-----BEGIN CERTIFICATE----- +MIIDIzCCAgugAwIBAgIJAMfISuBQ5m+5MA0GCSqGSIb3DQEBBQUAMBUxEzARBgNV +BAMTCnVuaXQtdGVzdHMwHhcNMTExMjA2MTYyNjAyWhcNMjExMjAzMTYyNjAyWjAV +MRMwEQYDVQQDEwp1bml0LXRlc3RzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEA4ej0p7bQ7L/r4rVGUz9RN4VQWoej1Bg1mYWIDYslvKrk1gpj7wZgkdmM +7oVK2OfgrSj/FCTkInKPqaCR0gD7K80q+mLBrN3PUkDrJQZpvRZIff3/xmVU1Wer +uQLFJjnFb2dqu0s/FY/2kWiJtBCakXvXEOb7zfbINuayL+MSsCGSdVYsSliS5qQp +gyDap+8b5fpXZVJkq92hrcNtbkg7hCYUJczt8n9hcCTJCfUpApvaFQ18pe+zpyl4 ++WzkP66I28hniMQyUlA1hBiskT7qiouq0m8IOodhv2fagSZKjOTTU2xkSBc//fy3 +ZpsL7WqgsZS7Q+0VRK8gKfqkxg5OYQIDAQABo3YwdDAdBgNVHQ4EFgQU2RQ8yO+O +gN8oVW2SW7RLrfYd9jEwRQYDVR0jBD4wPIAU2RQ8yO+OgN8oVW2SW7RLrfYd9jGh +GaQXMBUxEzARBgNVBAMTCnVuaXQtdGVzdHOCCQDHyErgUOZvuTAMBgNVHRMEBTAD +AQH/MA0GCSqGSIb3DQEBBQUAA4IBAQBRv+M/6+FiVu7KXNjFI5pSN17OcW5QUtPr +odJMlWrJBtynn/TA1oJlYu3yV5clc/71Vr/AxuX5xGP+IXL32YDF9lTUJXG/uUGk ++JETpKmQviPbRsvzYhz4pf6ZIOZMc3/GIcNq92ECbseGO+yAgyWUVKMmZM0HqXC9 +ovNslqe0M8C1sLm1zAR5z/h/litE7/8O2ietija3Q/qtl2TOXJdCA6sgjJX2WUql +ybrC55ct18NKf3qhpcEkGQvFU40rVYApJpi98DiZPYFdx1oBDp/f4uZ3ojpxRVFT +cDwcJLfNRCPUhormsY7fDS9xSyThiHsW9mjJYdcaKQkwYZ0F11yB +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIFtTCCA52gAwIBAgIJAPBsLZmNGfKtMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTYwOTIxMDI0NTEyWhcNMTYxMDIxMDI0NTEyWjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC +CgKCAgEAsiMC7mTsmUXwZoYlT4aHY1FLw8bxIXC+z3IqA+TY1WqfbeiZRo8MA5Zx +lTTxYMKPCZUE1XBc7jvD8GJhWIj6pToPYHn73B01IBkLBxq4kF1yV2Z7DVmkvc6H +EcxXXq8zkCx0j6XOfiI4+qkXnuQn8cvrk8xfhtnMMZM7iVm6VSN93iRP/8ey6xuL +XTHrDX7ukoRce1hpT8O+15GXNrY0irhhYQz5xKibNCJF3EjV28WMry8y7I8uYUFU +RWDiQawwK9ec1zhZ94v92+GZDlPevmcFmSERKYQ0NsKcT0Y3lGuGnaExs8GyOpnC +oksu4YJGXQjg7lkv4MxzsNbRqmCkUwxw1Mg6FP0tsCNsw9qTrkvWCRA9zp/aU+sZ +IBGh1t4UGCub8joeQFvHxvr/3F7mH/dyvCjA34u0Lo1VPx+jYUIi9i0odltMspDW +xOpjqdGARZYmlJP5Au9q5cQjPMcwS/EBIb8cwNl32mUE6WnFlep+38mNR/FghIjO +ViAkXuKQmcHe6xppZAoHFsO/t3l4Tjek5vNW7erI1rgrFku/fvkIW/G8V1yIm/+Q +F+CE4maQzCJfhftpkhM/sPC/FuLNBmNE8BHVX8y58xG4is/cQxL4Z9TsFIw0C5+3 +uTrFW9D0agysahMVzPGtCqhDQqJdIJrBQqlS6bztpzBA8zEI0skCAwEAAaOBpzCB +pDAdBgNVHQ4EFgQUz/8FmW6TfqXyNJZr7rhc+Tn5sKQwdQYDVR0jBG4wbIAUz/8F +mW6TfqXyNJZr7rhc+Tn5sKShSaRHMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpT +b21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGSCCQDw +bC2ZjRnyrTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4ICAQCQmrcfhurX +riR3Q0Y+nq040/3dJIAJXjyI9CEtxaU0nzCNTng7PwgZ0CKmCelQfInuwWFwBSHS +6kBfC1rgJeFnjnTt8a3RCgRlIgUr9NCdPSEccB7TurobwPJ2h6cJjjR8urcb0CXh +CEMvPneyPj0xUFY8vVKXMGWahz/kyfwIiVqcX/OtMZ29fUu1onbWl71g2gVLtUZl +sECdZ+AC/6HDCVpYIVETMl1T7N/XyqXZQiDLDNRDeZhnapz8w9fsW1KVujAZLNQR +pVnw2qa2UK1dSf2FHX+lQU5mFSYM4vtwaMlX/LgfdLZ9I796hFh619WwTVz+LO2N +vHnwBMabld3XSPuZRqlbBulDQ07Vbqdjv8DYSLA2aKI4ZkMMKuFLG/oS28V2ZYmv +/KpGEs5UgKY+P9NulYpTDwCU/6SomuQpP795wbG6sm7Hzq82r2RmB61GupNRGeqi +pXKsy69T388zBxYu6zQrosXiDl5YzaViH7tm0J7opye8dCWjjpnahki0vq2znti7 +6cWla2j8Xz1glvLz+JI/NCOMfxUInb82T7ijo80N0VJ2hzf7p2GxRZXAxAV9knLI +nM4F5TLjSd7ZhOOZ7ni/eZFueTMisWfypt2nc41whGjHMX/Zp1kPfhB4H2bLKIX/ +lSrwNr3qbGTEJX8JqpDBNVAd96XkMvDNyA== +-----END CERTIFICATE----- \ No newline at end of file diff --git a/packages/google-auth/tests/data/trust_chain_without_leaf.pem b/packages/google-auth/tests/data/trust_chain_without_leaf.pem new file mode 100644 index 000000000000..9da0f37fedf9 --- /dev/null +++ b/packages/google-auth/tests/data/trust_chain_without_leaf.pem @@ -0,0 +1,33 @@ +-----BEGIN CERTIFICATE----- +MIIFtTCCA52gAwIBAgIJAPBsLZmNGfKtMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTYwOTIxMDI0NTEyWhcNMTYxMDIxMDI0NTEyWjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC +CgKCAgEAsiMC7mTsmUXwZoYlT4aHY1FLw8bxIXC+z3IqA+TY1WqfbeiZRo8MA5Zx +lTTxYMKPCZUE1XBc7jvD8GJhWIj6pToPYHn73B01IBkLBxq4kF1yV2Z7DVmkvc6H +EcxXXq8zkCx0j6XOfiI4+qkXnuQn8cvrk8xfhtnMMZM7iVm6VSN93iRP/8ey6xuL +XTHrDX7ukoRce1hpT8O+15GXNrY0irhhYQz5xKibNCJF3EjV28WMry8y7I8uYUFU +RWDiQawwK9ec1zhZ94v92+GZDlPevmcFmSERKYQ0NsKcT0Y3lGuGnaExs8GyOpnC +oksu4YJGXQjg7lkv4MxzsNbRqmCkUwxw1Mg6FP0tsCNsw9qTrkvWCRA9zp/aU+sZ +IBGh1t4UGCub8joeQFvHxvr/3F7mH/dyvCjA34u0Lo1VPx+jYUIi9i0odltMspDW +xOpjqdGARZYmlJP5Au9q5cQjPMcwS/EBIb8cwNl32mUE6WnFlep+38mNR/FghIjO +ViAkXuKQmcHe6xppZAoHFsO/t3l4Tjek5vNW7erI1rgrFku/fvkIW/G8V1yIm/+Q +F+CE4maQzCJfhftpkhM/sPC/FuLNBmNE8BHVX8y58xG4is/cQxL4Z9TsFIw0C5+3 +uTrFW9D0agysahMVzPGtCqhDQqJdIJrBQqlS6bztpzBA8zEI0skCAwEAAaOBpzCB +pDAdBgNVHQ4EFgQUz/8FmW6TfqXyNJZr7rhc+Tn5sKQwdQYDVR0jBG4wbIAUz/8F +mW6TfqXyNJZr7rhc+Tn5sKShSaRHMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpT +b21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGSCCQDw +bC2ZjRnyrTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4ICAQCQmrcfhurX +riR3Q0Y+nq040/3dJIAJXjyI9CEtxaU0nzCNTng7PwgZ0CKmCelQfInuwWFwBSHS +6kBfC1rgJeFnjnTt8a3RCgRlIgUr9NCdPSEccB7TurobwPJ2h6cJjjR8urcb0CXh +CEMvPneyPj0xUFY8vVKXMGWahz/kyfwIiVqcX/OtMZ29fUu1onbWl71g2gVLtUZl +sECdZ+AC/6HDCVpYIVETMl1T7N/XyqXZQiDLDNRDeZhnapz8w9fsW1KVujAZLNQR +pVnw2qa2UK1dSf2FHX+lQU5mFSYM4vtwaMlX/LgfdLZ9I796hFh619WwTVz+LO2N +vHnwBMabld3XSPuZRqlbBulDQ07Vbqdjv8DYSLA2aKI4ZkMMKuFLG/oS28V2ZYmv +/KpGEs5UgKY+P9NulYpTDwCU/6SomuQpP795wbG6sm7Hzq82r2RmB61GupNRGeqi +pXKsy69T388zBxYu6zQrosXiDl5YzaViH7tm0J7opye8dCWjjpnahki0vq2znti7 +6cWla2j8Xz1glvLz+JI/NCOMfxUInb82T7ijo80N0VJ2hzf7p2GxRZXAxAV9knLI +nM4F5TLjSd7ZhOOZ7ni/eZFueTMisWfypt2nc41whGjHMX/Zp1kPfhB4H2bLKIX/ +lSrwNr3qbGTEJX8JqpDBNVAd96XkMvDNyA== +-----END CERTIFICATE----- \ No newline at end of file diff --git a/packages/google-auth/tests/data/trust_chain_wrong_order.pem b/packages/google-auth/tests/data/trust_chain_wrong_order.pem new file mode 100644 index 000000000000..e8dc5d359319 --- /dev/null +++ b/packages/google-auth/tests/data/trust_chain_wrong_order.pem @@ -0,0 +1,52 @@ +-----BEGIN CERTIFICATE----- +MIIFtTCCA52gAwIBAgIJAPBsLZmNGfKtMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTYwOTIxMDI0NTEyWhcNMTYxMDIxMDI0NTEyWjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC +CgKCAgEAsiMC7mTsmUXwZoYlT4aHY1FLw8bxIXC+z3IqA+TY1WqfbeiZRo8MA5Zx +lTTxYMKPCZUE1XBc7jvD8GJhWIj6pToPYHn73B01IBkLBxq4kF1yV2Z7DVmkvc6H +EcxXXq8zkCx0j6XOfiI4+qkXnuQn8cvrk8xfhtnMMZM7iVm6VSN93iRP/8ey6xuL +XTHrDX7ukoRce1hpT8O+15GXNrY0irhhYQz5xKibNCJF3EjV28WMry8y7I8uYUFU +RWDiQawwK9ec1zhZ94v92+GZDlPevmcFmSERKYQ0NsKcT0Y3lGuGnaExs8GyOpnC +oksu4YJGXQjg7lkv4MxzsNbRqmCkUwxw1Mg6FP0tsCNsw9qTrkvWCRA9zp/aU+sZ +IBGh1t4UGCub8joeQFvHxvr/3F7mH/dyvCjA34u0Lo1VPx+jYUIi9i0odltMspDW +xOpjqdGARZYmlJP5Au9q5cQjPMcwS/EBIb8cwNl32mUE6WnFlep+38mNR/FghIjO +ViAkXuKQmcHe6xppZAoHFsO/t3l4Tjek5vNW7erI1rgrFku/fvkIW/G8V1yIm/+Q +F+CE4maQzCJfhftpkhM/sPC/FuLNBmNE8BHVX8y58xG4is/cQxL4Z9TsFIw0C5+3 +uTrFW9D0agysahMVzPGtCqhDQqJdIJrBQqlS6bztpzBA8zEI0skCAwEAAaOBpzCB +pDAdBgNVHQ4EFgQUz/8FmW6TfqXyNJZr7rhc+Tn5sKQwdQYDVR0jBG4wbIAUz/8F +mW6TfqXyNJZr7rhc+Tn5sKShSaRHMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpT +b21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGSCCQDw +bC2ZjRnyrTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4ICAQCQmrcfhurX +riR3Q0Y+nq040/3dJIAJXjyI9CEtxaU0nzCNTng7PwgZ0CKmCelQfInuwWFwBSHS +6kBfC1rgJeFnjnTt8a3RCgRlIgUr9NCdPSEccB7TurobwPJ2h6cJjjR8urcb0CXh +CEMvPneyPj0xUFY8vVKXMGWahz/kyfwIiVqcX/OtMZ29fUu1onbWl71g2gVLtUZl +sECdZ+AC/6HDCVpYIVETMl1T7N/XyqXZQiDLDNRDeZhnapz8w9fsW1KVujAZLNQR +pVnw2qa2UK1dSf2FHX+lQU5mFSYM4vtwaMlX/LgfdLZ9I796hFh619WwTVz+LO2N +vHnwBMabld3XSPuZRqlbBulDQ07Vbqdjv8DYSLA2aKI4ZkMMKuFLG/oS28V2ZYmv +/KpGEs5UgKY+P9NulYpTDwCU/6SomuQpP795wbG6sm7Hzq82r2RmB61GupNRGeqi +pXKsy69T388zBxYu6zQrosXiDl5YzaViH7tm0J7opye8dCWjjpnahki0vq2znti7 +6cWla2j8Xz1glvLz+JI/NCOMfxUInb82T7ijo80N0VJ2hzf7p2GxRZXAxAV9knLI +nM4F5TLjSd7ZhOOZ7ni/eZFueTMisWfypt2nc41whGjHMX/Zp1kPfhB4H2bLKIX/ +lSrwNr3qbGTEJX8JqpDBNVAd96XkMvDNyA== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDIzCCAgugAwIBAgIJAMfISuBQ5m+5MA0GCSqGSIb3DQEBBQUAMBUxEzARBgNV +BAMTCnVuaXQtdGVzdHMwHhcNMTExMjA2MTYyNjAyWhcNMjExMjAzMTYyNjAyWjAV +MRMwEQYDVQQDEwp1bml0LXRlc3RzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEA4ej0p7bQ7L/r4rVGUz9RN4VQWoej1Bg1mYWIDYslvKrk1gpj7wZgkdmM +7oVK2OfgrSj/FCTkInKPqaCR0gD7K80q+mLBrN3PUkDrJQZpvRZIff3/xmVU1Wer +uQLFJjnFb2dqu0s/FY/2kWiJtBCakXvXEOb7zfbINuayL+MSsCGSdVYsSliS5qQp +gyDap+8b5fpXZVJkq92hrcNtbkg7hCYUJczt8n9hcCTJCfUpApvaFQ18pe+zpyl4 ++WzkP66I28hniMQyUlA1hBiskT7qiouq0m8IOodhv2fagSZKjOTTU2xkSBc//fy3 +ZpsL7WqgsZS7Q+0VRK8gKfqkxg5OYQIDAQABo3YwdDAdBgNVHQ4EFgQU2RQ8yO+O +gN8oVW2SW7RLrfYd9jEwRQYDVR0jBD4wPIAU2RQ8yO+OgN8oVW2SW7RLrfYd9jGh +GaQXMBUxEzARBgNVBAMTCnVuaXQtdGVzdHOCCQDHyErgUOZvuTAMBgNVHRMEBTAD +AQH/MA0GCSqGSIb3DQEBBQUAA4IBAQBRv+M/6+FiVu7KXNjFI5pSN17OcW5QUtPr +odJMlWrJBtynn/TA1oJlYu3yV5clc/71Vr/AxuX5xGP+IXL32YDF9lTUJXG/uUGk ++JETpKmQviPbRsvzYhz4pf6ZIOZMc3/GIcNq92ECbseGO+yAgyWUVKMmZM0HqXC9 +ovNslqe0M8C1sLm1zAR5z/h/litE7/8O2ietija3Q/qtl2TOXJdCA6sgjJX2WUql +ybrC55ct18NKf3qhpcEkGQvFU40rVYApJpi98DiZPYFdx1oBDp/f4uZ3ojpxRVFT +cDwcJLfNRCPUhormsY7fDS9xSyThiHsW9mjJYdcaKQkwYZ0F11yB +-----END CERTIFICATE----- \ No newline at end of file diff --git a/packages/google-auth/tests/oauth2/__init__.py b/packages/google-auth/tests/oauth2/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/google-auth/tests/oauth2/test__client.py b/packages/google-auth/tests/oauth2/test__client.py new file mode 100644 index 000000000000..b17ba542d085 --- /dev/null +++ b/packages/google-auth/tests/oauth2/test__client.py @@ -0,0 +1,807 @@ +# Copyright 2016 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 datetime +import http.client as http_client +import json +import os +import urllib + +import mock +import pytest # type: ignore + +from google.auth import _helpers +from google.auth import crypt +from google.auth import exceptions +from google.auth import iam +from google.auth import jwt +from google.auth import transport +from google.oauth2 import _client + + +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() + +SIGNER = crypt.RSASigner.from_string(PRIVATE_KEY_BYTES, "1") + +SCOPES_AS_LIST = [ + "https://www.googleapis.com/auth/pubsub", + "https://www.googleapis.com/auth/logging.write", +] +SCOPES_AS_STRING = ( + "https://www.googleapis.com/auth/pubsub" + " https://www.googleapis.com/auth/logging.write" +) + +ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE = ( + "gl-python/3.7 auth/1.1 auth-request-type/at cred-type/sa" +) +ID_TOKEN_REQUEST_METRICS_HEADER_VALUE = ( + "gl-python/3.7 auth/1.1 auth-request-type/it cred-type/sa" +) + + +@pytest.mark.parametrize("retryable", [True, False]) +def test__handle_error_response(retryable): + response_data = {"error": "help", "error_description": "I'm alive"} + + with pytest.raises(exceptions.RefreshError) as excinfo: + _client._handle_error_response(response_data, retryable) + + assert excinfo.value.retryable == retryable + assert excinfo.match(r"help: I\'m alive") + + +def test__handle_error_response_no_error(): + response_data = {"foo": "bar"} + + with pytest.raises(exceptions.RefreshError) as excinfo: + _client._handle_error_response(response_data, False) + + assert not excinfo.value.retryable + assert excinfo.match(r"{\"foo\": \"bar\"}") + + +def test__handle_error_response_not_json(): + response_data = "this is an error message" + + with pytest.raises(exceptions.RefreshError) as excinfo: + _client._handle_error_response(response_data, False) + + assert not excinfo.value.retryable + assert excinfo.match(response_data) + + +def test__can_retry_retryable(): + retryable_codes = transport.DEFAULT_RETRYABLE_STATUS_CODES + for status_code in range(100, 600): + if status_code in retryable_codes: + assert _client._can_retry(status_code, {"error": "invalid_scope"}) + else: + assert not _client._can_retry(status_code, {"error": "invalid_scope"}) + + +@pytest.mark.parametrize( + "response_data", [{"error": "internal_failure"}, {"error": "server_error"}] +) +def test__can_retry_message(response_data): + assert _client._can_retry(http_client.OK, response_data) + + +@pytest.mark.parametrize( + "response_data", + [ + {"error": "invalid_scope"}, + {"error": {"foo": "bar"}}, + {"error_description": {"foo", "bar"}}, + ], +) +def test__can_retry_no_retry_message(response_data): + assert not _client._can_retry(http_client.OK, response_data) + + +@pytest.mark.parametrize("mock_expires_in", [500, "500"]) +@mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) +def test__parse_expiry(unused_utcnow, mock_expires_in): + result = _client._parse_expiry({"expires_in": mock_expires_in}) + assert result == datetime.datetime.min + datetime.timedelta(seconds=500) + + +def test__parse_expiry_none(): + assert _client._parse_expiry({}) is None + + +def make_request(response_data, status=http_client.OK): + response = mock.create_autospec(transport.Response, instance=True) + response.status = status + response.data = json.dumps(response_data).encode("utf-8") + request = mock.create_autospec(transport.Request) + request.return_value = response + return request + + +def test__token_endpoint_request(): + request = make_request({"test": "response"}) + + result = _client._token_endpoint_request( + request, "http://example.com", {"test": "params"} + ) + + # Check request call + request.assert_called_with( + method="POST", + url="http://example.com", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + body="test=params".encode("utf-8"), + ) + + # Check result + assert result == {"test": "response"} + + +def test__token_endpoint_request_use_json(): + request = make_request({"test": "response"}) + + result = _client._token_endpoint_request( + request, + "http://example.com", + {"test": "params"}, + access_token="access_token", + use_json=True, + ) + + # Check request call + request.assert_called_with( + method="POST", + url="http://example.com", + headers={ + "Content-Type": "application/json", + "Authorization": "Bearer access_token", + }, + body=b'{"test": "params"}', + ) + + # Check result + assert result == {"test": "response"} + + +def test__token_endpoint_request_error(): + request = make_request({}, status=http_client.BAD_REQUEST) + + with pytest.raises(exceptions.RefreshError): + _client._token_endpoint_request(request, "http://example.com", {}) + + +def test__token_endpoint_request_internal_failure_error(): + request = make_request( + {"error_description": "internal_failure"}, status=http_client.BAD_REQUEST + ) + + with pytest.raises(exceptions.RefreshError): + _client._token_endpoint_request( + request, "http://example.com", {"error_description": "internal_failure"} + ) + # request with 2 retries + assert request.call_count == 3 + + request = make_request( + {"error": "internal_failure"}, status=http_client.BAD_REQUEST + ) + + with pytest.raises(exceptions.RefreshError): + _client._token_endpoint_request( + request, "http://example.com", {"error": "internal_failure"} + ) + # request with 2 retries + assert request.call_count == 3 + + +def test__token_endpoint_request_internal_failure_and_retry_failure_error(): + retryable_error = mock.create_autospec(transport.Response, instance=True) + retryable_error.status = http_client.BAD_REQUEST + retryable_error.data = json.dumps({"error_description": "internal_failure"}).encode( + "utf-8" + ) + + unretryable_error = mock.create_autospec(transport.Response, instance=True) + unretryable_error.status = http_client.BAD_REQUEST + unretryable_error.data = json.dumps({"error_description": "invalid_scope"}).encode( + "utf-8" + ) + + request = mock.create_autospec(transport.Request) + + request.side_effect = [retryable_error, retryable_error, unretryable_error] + + with pytest.raises(exceptions.RefreshError): + _client._token_endpoint_request( + request, "http://example.com", {"error_description": "invalid_scope"} + ) + # request should be called three times. Two retryable errors and one + # unretryable error to break the retry loop. + assert request.call_count == 3 + + +def test__token_endpoint_request_internal_failure_and_retry_succeeds(): + retryable_error = mock.create_autospec(transport.Response, instance=True) + retryable_error.status = http_client.BAD_REQUEST + retryable_error.data = json.dumps({"error_description": "internal_failure"}).encode( + "utf-8" + ) + + response = mock.create_autospec(transport.Response, instance=True) + response.status = http_client.OK + response.data = json.dumps({"hello": "world"}).encode("utf-8") + + request = mock.create_autospec(transport.Request) + + request.side_effect = [retryable_error, response] + + _ = _client._token_endpoint_request( + request, "http://example.com", {"test": "params"} + ) + + assert request.call_count == 2 + + +def test__token_endpoint_request_string_error(): + response = mock.create_autospec(transport.Response, instance=True) + response.status = http_client.BAD_REQUEST + response.data = "this is an error message" + request = mock.create_autospec(transport.Request) + request.return_value = response + + with pytest.raises(exceptions.RefreshError) as excinfo: + _client._token_endpoint_request(request, "http://example.com", {}) + assert excinfo.match("this is an error message") + + +def verify_request_params(request, params): + request_body = request.call_args[1]["body"].decode("utf-8") + request_params = urllib.parse.parse_qs(request_body) + + for key, value in params.items(): + assert request_params[key][0] == value + + +@mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) +def test_jwt_grant(utcnow): + request = make_request( + {"access_token": "token", "expires_in": 500, "extra": "data"} + ) + + token, expiry, extra_data = _client.jwt_grant( + request, "http://example.com", "assertion_value" + ) + + # Check request call + verify_request_params( + request, {"grant_type": _client._JWT_GRANT_TYPE, "assertion": "assertion_value"} + ) + + # Check result + assert token == "token" + assert expiry == utcnow() + datetime.timedelta(seconds=500) + assert extra_data["extra"] == "data" + + +def test_jwt_grant_no_access_token(): + request = make_request( + { + # No access token. + "expires_in": 500, + "extra": "data", + } + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + _client.jwt_grant(request, "http://example.com", "assertion_value") + 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, + iam._IAM_IDTOKEN_ENDPOINT, + "fake_email", + "fake_audience", + "fake_access_token", + universe_domain="googleapis.com", + ) + + 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" + assert response_body["useEmailAzp"] == "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, + iam._IAM_IDTOKEN_ENDPOINT, + "fake_email", + "fake_audience", + "fake_access_token", + "googleapis.com", + ) + 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) + id_token = jwt.encode(SIGNER, {"exp": id_token_expiry}).decode("utf-8") + request = make_request({"id_token": id_token, "extra": "data"}) + + token, expiry, extra_data = _client.id_token_jwt_grant( + request, "http://example.com", "assertion_value" + ) + + # Check request call + verify_request_params( + request, {"grant_type": _client._JWT_GRANT_TYPE, "assertion": "assertion_value"} + ) + + # Check result + assert token == id_token + # JWT does not store microseconds + now = now.replace(microsecond=0) + assert expiry == now + assert extra_data["extra"] == "data" + + +def test_id_token_jwt_grant_no_access_token(): + request = make_request( + { + # No access token. + "expires_in": 500, + "extra": "data", + } + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + _client.id_token_jwt_grant(request, "http://example.com", "assertion_value") + assert not excinfo.value.retryable + + +@mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) +def test_refresh_grant(unused_utcnow): + request = make_request( + { + "access_token": "token", + "refresh_token": "new_refresh_token", + "expires_in": 500, + "extra": "data", + } + ) + + token, refresh_token, expiry, extra_data = _client.refresh_grant( + request, + "http://example.com", + "refresh_token", + "client_id", + "client_secret", + rapt_token="rapt_token", + ) + + # Check request call + verify_request_params( + request, + { + "grant_type": _client._REFRESH_GRANT_TYPE, + "refresh_token": "refresh_token", + "client_id": "client_id", + "client_secret": "client_secret", + "rapt": "rapt_token", + }, + ) + + # Check result + assert token == "token" + assert refresh_token == "new_refresh_token" + assert expiry == datetime.datetime.min + datetime.timedelta(seconds=500) + assert extra_data["extra"] == "data" + + +@mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) +def test_refresh_grant_with_scopes(unused_utcnow): + request = make_request( + { + "access_token": "token", + "refresh_token": "new_refresh_token", + "expires_in": 500, + "extra": "data", + "scope": SCOPES_AS_STRING, + } + ) + + token, refresh_token, expiry, extra_data = _client.refresh_grant( + request, + "http://example.com", + "refresh_token", + "client_id", + "client_secret", + SCOPES_AS_LIST, + ) + + # Check request call. + verify_request_params( + request, + { + "grant_type": _client._REFRESH_GRANT_TYPE, + "refresh_token": "refresh_token", + "client_id": "client_id", + "client_secret": "client_secret", + "scope": SCOPES_AS_STRING, + }, + ) + + # Check result. + assert token == "token" + assert refresh_token == "new_refresh_token" + assert expiry == datetime.datetime.min + datetime.timedelta(seconds=500) + assert extra_data["extra"] == "data" + + +def test_refresh_grant_no_access_token(): + request = make_request( + { + # No access token. + "refresh_token": "new_refresh_token", + "expires_in": 500, + "extra": "data", + } + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + _client.refresh_grant( + request, "http://example.com", "refresh_token", "client_id", "client_secret" + ) + assert not excinfo.value.retryable + + +@mock.patch( + "google.auth.metrics.token_request_access_token_sa_assertion", + return_value=ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, +) +@mock.patch("google.oauth2._client._parse_expiry", return_value=None) +@mock.patch.object(_client, "_token_endpoint_request", autospec=True) +def test_jwt_grant_retry_default( + mock_token_endpoint_request, mock_expiry, mock_metrics_header_value +): + _client.jwt_grant(mock.Mock(), mock.Mock(), mock.Mock()) + mock_token_endpoint_request.assert_called_with( + mock.ANY, + mock.ANY, + mock.ANY, + can_retry=True, + headers={"x-goog-api-client": ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE}, + ) + + +@pytest.mark.parametrize("can_retry", [True, False]) +@mock.patch( + "google.auth.metrics.token_request_access_token_sa_assertion", + return_value=ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, +) +@mock.patch("google.oauth2._client._parse_expiry", return_value=None) +@mock.patch.object(_client, "_token_endpoint_request", autospec=True) +def test_jwt_grant_retry_with_retry( + mock_token_endpoint_request, mock_expiry, mock_metrics_header_value, can_retry +): + _client.jwt_grant(mock.Mock(), mock.Mock(), mock.Mock(), can_retry=can_retry) + mock_token_endpoint_request.assert_called_with( + mock.ANY, + mock.ANY, + mock.ANY, + can_retry=can_retry, + headers={"x-goog-api-client": ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE}, + ) + + +@mock.patch( + "google.auth.metrics.token_request_id_token_sa_assertion", + return_value=ID_TOKEN_REQUEST_METRICS_HEADER_VALUE, +) +@mock.patch("google.auth.jwt.decode", return_value={"exp": 0}) +@mock.patch.object(_client, "_token_endpoint_request", autospec=True) +def test_id_token_jwt_grant_retry_default( + mock_token_endpoint_request, mock_jwt_decode, mock_metrics_header_value +): + _client.id_token_jwt_grant(mock.Mock(), mock.Mock(), mock.Mock()) + mock_token_endpoint_request.assert_called_with( + mock.ANY, + mock.ANY, + mock.ANY, + can_retry=True, + headers={"x-goog-api-client": ID_TOKEN_REQUEST_METRICS_HEADER_VALUE}, + ) + + +@pytest.mark.parametrize("can_retry", [True, False]) +@mock.patch( + "google.auth.metrics.token_request_id_token_sa_assertion", + return_value=ID_TOKEN_REQUEST_METRICS_HEADER_VALUE, +) +@mock.patch("google.auth.jwt.decode", return_value={"exp": 0}) +@mock.patch.object(_client, "_token_endpoint_request", autospec=True) +def test_id_token_jwt_grant_retry_with_retry( + mock_token_endpoint_request, mock_jwt_decode, mock_metrics_header_value, can_retry +): + _client.id_token_jwt_grant( + mock.Mock(), mock.Mock(), mock.Mock(), can_retry=can_retry + ) + mock_token_endpoint_request.assert_called_with( + mock.ANY, + mock.ANY, + mock.ANY, + can_retry=can_retry, + headers={"x-goog-api-client": ID_TOKEN_REQUEST_METRICS_HEADER_VALUE}, + ) + + +@mock.patch("google.oauth2._client._parse_expiry", return_value=None) +@mock.patch.object(_client, "_token_endpoint_request", autospec=True) +def test_refresh_grant_retry_default(mock_token_endpoint_request, mock_parse_expiry): + _client.refresh_grant( + mock.Mock(), mock.Mock(), mock.Mock(), mock.Mock(), mock.Mock() + ) + mock_token_endpoint_request.assert_called_with( + mock.ANY, mock.ANY, mock.ANY, can_retry=True + ) + + +@pytest.mark.parametrize("can_retry", [True, False]) +@mock.patch("google.oauth2._client._parse_expiry", return_value=None) +@mock.patch.object(_client, "_token_endpoint_request", autospec=True) +def test_refresh_grant_retry_with_retry( + mock_token_endpoint_request, mock_parse_expiry, can_retry +): + _client.refresh_grant( + mock.Mock(), + mock.Mock(), + mock.Mock(), + mock.Mock(), + mock.Mock(), + can_retry=can_retry, + ) + mock_token_endpoint_request.assert_called_with( + mock.ANY, mock.ANY, mock.ANY, can_retry=can_retry + ) + + +@pytest.mark.parametrize("can_retry", [True, False]) +def test__token_endpoint_request_no_throw_with_retry(can_retry): + response_data = {"error": "help", "error_description": "I'm alive"} + body = "dummy body" + + mock_response = mock.create_autospec(transport.Response, instance=True) + mock_response.status = http_client.INTERNAL_SERVER_ERROR + mock_response.data = json.dumps(response_data).encode("utf-8") + + mock_request = mock.create_autospec(transport.Request) + mock_request.return_value = mock_response + + _client._token_endpoint_request_no_throw( + mock_request, mock.Mock(), body, mock.Mock(), mock.Mock(), can_retry=can_retry + ) + + if can_retry: + assert mock_request.call_count == 3 + else: + assert mock_request.call_count == 1 + + +def test_lookup_trust_boundary(): + response_data = { + "locations": ["us-central1", "us-east1"], + "encodedLocations": "0x80080000000000", + } + + mock_response = mock.create_autospec(transport.Response, instance=True) + mock_response.status = http_client.OK + mock_response.data = json.dumps(response_data).encode("utf-8") + + mock_request = mock.create_autospec(transport.Request) + mock_request.return_value = mock_response + + url = "http://example.com" + headers = {"Authorization": "Bearer access_token"} + response = _client._lookup_trust_boundary(mock_request, url, headers=headers) + + assert response["encodedLocations"] == "0x80080000000000" + assert response["locations"] == ["us-central1", "us-east1"] + + mock_request.assert_called_once_with(method="GET", url=url, headers=headers) + + +def test_lookup_trust_boundary_no_op_response_without_locations(): + response_data = {"encodedLocations": "0x0"} + + mock_response = mock.create_autospec(transport.Response, instance=True) + mock_response.status = http_client.OK + mock_response.data = json.dumps(response_data).encode("utf-8") + + mock_request = mock.create_autospec(transport.Request) + mock_request.return_value = mock_response + + url = "http://example.com" + headers = {"Authorization": "Bearer access_token"} + # for the response to be valid, we only need encodedLocations to be present. + response = _client._lookup_trust_boundary(mock_request, url, headers=headers) + assert response["encodedLocations"] == "0x0" + assert "locations" not in response + + mock_request.assert_called_once_with(method="GET", url=url, headers=headers) + + +def test_lookup_trust_boundary_no_op_response(): + response_data = {"locations": [], "encodedLocations": "0x0"} + + mock_response = mock.create_autospec(transport.Response, instance=True) + mock_response.status = http_client.OK + mock_response.data = json.dumps(response_data).encode("utf-8") + + mock_request = mock.create_autospec(transport.Request) + mock_request.return_value = mock_response + + url = "http://example.com" + headers = {"Authorization": "Bearer access_token"} + response = _client._lookup_trust_boundary(mock_request, url, headers=headers) + + assert response["encodedLocations"] == "0x0" + assert response["locations"] == [] + + mock_request.assert_called_once_with(method="GET", url=url, headers=headers) + + +def test_lookup_trust_boundary_error(): + mock_response = mock.create_autospec(transport.Response, instance=True) + mock_response.status = http_client.INTERNAL_SERVER_ERROR + mock_response.data = "this is an error message" + + mock_request = mock.create_autospec(transport.Request) + mock_request.return_value = mock_response + + url = "http://example.com" + headers = {"Authorization": "Bearer access_token"} + with pytest.raises(exceptions.RefreshError) as excinfo: + _client._lookup_trust_boundary(mock_request, url, headers=headers) + assert excinfo.match("this is an error message") + + mock_request.assert_called_with(method="GET", url=url, headers=headers) + + +def test_lookup_trust_boundary_missing_encoded_locations(): + response_data = {"locations": [], "bad_field": "0x0"} + + mock_response = mock.create_autospec(transport.Response, instance=True) + mock_response.status = http_client.OK + mock_response.data = json.dumps(response_data).encode("utf-8") + + mock_request = mock.create_autospec(transport.Request) + mock_request.return_value = mock_response + + url = "http://example.com" + headers = {"Authorization": "Bearer access_token"} + with pytest.raises(exceptions.MalformedError) as excinfo: + _client._lookup_trust_boundary(mock_request, url, headers=headers) + assert excinfo.match("Invalid trust boundary info") + + mock_request.assert_called_once_with(method="GET", url=url, headers=headers) + + +def test_lookup_trust_boundary_internal_failure_and_retry_failure_error(): + retryable_error = mock.create_autospec(transport.Response, instance=True) + retryable_error.status = http_client.BAD_REQUEST + retryable_error.data = json.dumps({"error_description": "internal_failure"}).encode( + "utf-8" + ) + + unretryable_error = mock.create_autospec(transport.Response, instance=True) + unretryable_error.status = http_client.BAD_REQUEST + unretryable_error.data = json.dumps({"error_description": "invalid_scope"}).encode( + "utf-8" + ) + + request = mock.create_autospec(transport.Request) + + request.side_effect = [retryable_error, retryable_error, unretryable_error] + headers = {"Authorization": "Bearer access_token"} + + with pytest.raises(exceptions.RefreshError): + _client._lookup_trust_boundary_request( + request, "http://example.com", headers=headers + ) + # request should be called three times. Two retryable errors and one + # unretryable error to break the retry loop. + assert request.call_count == 3 + for call in request.call_args_list: + assert call[1]["headers"] == headers + + +def test_lookup_trust_boundary_internal_failure_and_retry_succeeds(): + retryable_error = mock.create_autospec(transport.Response, instance=True) + retryable_error.status = http_client.BAD_REQUEST + retryable_error.data = json.dumps({"error_description": "internal_failure"}).encode( + "utf-8" + ) + + response_data = {"locations": [], "encodedLocations": "0x0"} + response = mock.create_autospec(transport.Response, instance=True) + response.status = http_client.OK + response.data = json.dumps(response_data).encode("utf-8") + + request = mock.create_autospec(transport.Request) + + headers = {"Authorization": "Bearer access_token"} + request.side_effect = [retryable_error, response] + + _ = _client._lookup_trust_boundary_request( + request, "http://example.com", headers=headers + ) + + assert request.call_count == 2 + for call in request.call_args_list: + assert call[1]["headers"] == headers + + +def test_lookup_trust_boundary_with_headers(): + response_data = { + "locations": ["us-central1", "us-east1"], + "encodedLocations": "0x80080000000000", + } + + mock_response = mock.create_autospec(transport.Response, instance=True) + mock_response.status = http_client.OK + mock_response.data = json.dumps(response_data).encode("utf-8") + + mock_request = mock.create_autospec(transport.Request) + mock_request.return_value = mock_response + headers = {"Authorization": "Bearer access_token", "x-test-header": "test-value"} + + _client._lookup_trust_boundary(mock_request, "http://example.com", headers=headers) + + mock_request.assert_called_once_with( + method="GET", url="http://example.com", headers=headers + ) diff --git a/packages/google-auth/tests/oauth2/test_challenges.py b/packages/google-auth/tests/oauth2/test_challenges.py new file mode 100644 index 000000000000..8be39c6ef5af --- /dev/null +++ b/packages/google-auth/tests/oauth2/test_challenges.py @@ -0,0 +1,350 @@ +# Copyright 2021 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. + +"""Tests for the reauth module.""" + +import base64 +import os +import sys + +import mock +import pytest # type: ignore +import pyu2f # type: ignore + +from google.auth import exceptions +from google.oauth2 import challenges +from google.oauth2.webauthn_types import ( + AuthenticationExtensionsClientInputs, + AuthenticatorAssertionResponse, + GetRequest, + GetResponse, + PublicKeyCredentialDescriptor, +) + + +def test_get_user_password(): + with mock.patch("getpass.getpass", return_value="foo"): + assert challenges.get_user_password("") == "foo" + + +def test_security_key(): + metadata = { + "status": "READY", + "challengeId": 2, + "challengeType": "SECURITY_KEY", + "securityKey": { + "applicationId": "security_key_application_id", + "challenges": [ + { + "keyHandle": "some_key", + "challenge": base64.urlsafe_b64encode( + "some_challenge".encode("ascii") + ).decode("ascii"), + } + ], + "relyingPartyId": "security_key_application_id", + }, + } + mock_key = mock.Mock() + + challenge = challenges.SecurityKeyChallenge() + + # Test the case that security key challenge is passed with applicationId and + # relyingPartyId the same. + os.environ.pop('"GOOGLE_AUTH_WEBAUTHN_PLUGIN"', None) + + with mock.patch("pyu2f.model.RegisteredKey", return_value=mock_key): + with mock.patch( + "pyu2f.convenience.authenticator.CompositeAuthenticator.Authenticate" + ) as mock_authenticate: + mock_authenticate.return_value = "security key response" + assert challenge.name == "SECURITY_KEY" + assert challenge.is_locally_eligible + assert challenge.obtain_challenge_input(metadata) == { + "securityKey": "security key response" + } + mock_authenticate.assert_called_with( + "security_key_application_id", + [{"key": mock_key, "challenge": b"some_challenge"}], + print_callback=sys.stderr.write, + ) + + # Test the case that webauthn plugin is available + os.environ["GOOGLE_AUTH_WEBAUTHN_PLUGIN"] = "plugin" + + with mock.patch( + "google.oauth2.challenges.SecurityKeyChallenge._obtain_challenge_input_webauthn", + return_value={"securityKey": "security key response"}, + ): + assert challenge.obtain_challenge_input(metadata) == { + "securityKey": "security key response" + } + os.environ.pop('"GOOGLE_AUTH_WEBAUTHN_PLUGIN"', None) + + # Test the case that security key challenge is passed with applicationId and + # relyingPartyId different, first call works. + metadata["securityKey"]["relyingPartyId"] = "security_key_relying_party_id" + sys.stderr.write("metadata=" + str(metadata) + "\n") + with mock.patch("pyu2f.model.RegisteredKey", return_value=mock_key): + with mock.patch( + "pyu2f.convenience.authenticator.CompositeAuthenticator.Authenticate" + ) as mock_authenticate: + mock_authenticate.return_value = "security key response" + assert challenge.name == "SECURITY_KEY" + assert challenge.is_locally_eligible + assert challenge.obtain_challenge_input(metadata) == { + "securityKey": "security key response" + } + mock_authenticate.assert_called_with( + "security_key_relying_party_id", + [{"key": mock_key, "challenge": b"some_challenge"}], + print_callback=sys.stderr.write, + ) + + # Test the case that security key challenge is passed with applicationId and + # relyingPartyId different, first call fails, requires retry. + metadata["securityKey"]["relyingPartyId"] = "security_key_relying_party_id" + with mock.patch("pyu2f.model.RegisteredKey", return_value=mock_key): + with mock.patch( + "pyu2f.convenience.authenticator.CompositeAuthenticator.Authenticate" + ) as mock_authenticate: + assert challenge.name == "SECURITY_KEY" + assert challenge.is_locally_eligible + mock_authenticate.side_effect = [ + pyu2f.errors.U2FError(pyu2f.errors.U2FError.DEVICE_INELIGIBLE), + "security key response", + ] + assert challenge.obtain_challenge_input(metadata) == { + "securityKey": "security key response" + } + calls = [ + mock.call( + "security_key_relying_party_id", + [{"key": mock_key, "challenge": b"some_challenge"}], + print_callback=sys.stderr.write, + ), + mock.call( + "security_key_application_id", + [{"key": mock_key, "challenge": b"some_challenge"}], + print_callback=sys.stderr.write, + ), + ] + mock_authenticate.assert_has_calls(calls) + + # Test various types of exceptions. + with mock.patch("pyu2f.model.RegisteredKey", return_value=mock_key): + with mock.patch( + "pyu2f.convenience.authenticator.CompositeAuthenticator.Authenticate" + ) as mock_authenticate: + mock_authenticate.side_effect = pyu2f.errors.U2FError( + pyu2f.errors.U2FError.DEVICE_INELIGIBLE + ) + assert challenge.obtain_challenge_input(metadata) is None + + with mock.patch( + "pyu2f.convenience.authenticator.CompositeAuthenticator.Authenticate" + ) as mock_authenticate: + mock_authenticate.side_effect = pyu2f.errors.U2FError( + pyu2f.errors.U2FError.TIMEOUT + ) + assert challenge.obtain_challenge_input(metadata) is None + + with mock.patch( + "pyu2f.convenience.authenticator.CompositeAuthenticator.Authenticate" + ) as mock_authenticate: + mock_authenticate.side_effect = pyu2f.errors.PluginError() + assert challenge.obtain_challenge_input(metadata) is None + + with mock.patch( + "pyu2f.convenience.authenticator.CompositeAuthenticator.Authenticate" + ) as mock_authenticate: + mock_authenticate.side_effect = pyu2f.errors.U2FError( + pyu2f.errors.U2FError.BAD_REQUEST + ) + with pytest.raises(pyu2f.errors.U2FError): + challenge.obtain_challenge_input(metadata) + + with mock.patch( + "pyu2f.convenience.authenticator.CompositeAuthenticator.Authenticate" + ) as mock_authenticate: + mock_authenticate.side_effect = pyu2f.errors.NoDeviceFoundError() + assert challenge.obtain_challenge_input(metadata) is None + + with mock.patch( + "pyu2f.convenience.authenticator.CompositeAuthenticator.Authenticate" + ) as mock_authenticate: + mock_authenticate.side_effect = pyu2f.errors.UnsupportedVersionException() + with pytest.raises(pyu2f.errors.UnsupportedVersionException): + challenge.obtain_challenge_input(metadata) + + with mock.patch.dict("sys.modules"): + sys.modules["pyu2f"] = None + with pytest.raises(exceptions.ReauthFailError) as excinfo: + challenge.obtain_challenge_input(metadata) + assert excinfo.match(r"pyu2f dependency is required") + + +def test_security_key_webauthn(): + metadata = { + "status": "READY", + "challengeId": 2, + "challengeType": "SECURITY_KEY", + "securityKey": { + "applicationId": "security_key_application_id", + "challenges": [ + { + "keyHandle": "some_key", + "challenge": base64.urlsafe_b64encode( + "some_challenge".encode("ascii") + ).decode("ascii"), + } + ], + "relyingPartyId": "security_key_application_id", + }, + } + + challenge = challenges.SecurityKeyChallenge() + + sk = metadata["securityKey"] + sk_challenges = sk["challenges"] + + application_id = sk["applicationId"] + + allow_credentials = [] + for sk_challenge in sk_challenges: + allow_credentials.append( + PublicKeyCredentialDescriptor(id=sk_challenge["keyHandle"]) + ) + + extension = AuthenticationExtensionsClientInputs(appid=application_id) + + get_request = GetRequest( + origin=challenges.REAUTH_ORIGIN, + rpid=application_id, + challenge=challenge._unpadded_urlsafe_b64recode(sk_challenge["challenge"]), + timeout_ms=challenges.WEBAUTHN_TIMEOUT_MS, + allow_credentials=allow_credentials, + user_verification="required", + extensions=extension, + ) + + assertion_resp = AuthenticatorAssertionResponse( + client_data_json="clientDataJSON", + authenticator_data="authenticatorData", + signature="signature", + user_handle="userHandle", + ) + get_response = GetResponse( + id="id", + response=assertion_resp, + authenticator_attachment="authenticatorAttachment", + client_extension_results="clientExtensionResults", + ) + response = { + "clientData": get_response.response.client_data_json, + "authenticatorData": get_response.response.authenticator_data, + "signatureData": get_response.response.signature, + "applicationId": "security_key_application_id", + "keyHandle": get_response.id, + "securityKeyReplyType": 2, + } + + mock_handler = mock.Mock() + mock_handler.get.return_value = get_response + + # Test success case + assert challenge._obtain_challenge_input_webauthn(metadata, mock_handler) == { + "securityKey": response + } + mock_handler.get.assert_called_with(get_request) + + # Test exceptions + + # Missing Values + sk = metadata["securityKey"] + metadata["securityKey"] = None + with pytest.raises(exceptions.InvalidValue): + challenge._obtain_challenge_input_webauthn(metadata, mock_handler) + metadata["securityKey"] = sk + + c = metadata["securityKey"]["challenges"] + metadata["securityKey"]["challenges"] = None + with pytest.raises(exceptions.InvalidValue): + challenge._obtain_challenge_input_webauthn(metadata, mock_handler) + metadata["securityKey"]["challenges"] = [] + with pytest.raises(exceptions.InvalidValue): + challenge._obtain_challenge_input_webauthn(metadata, mock_handler) + metadata["securityKey"]["challenges"] = c + + aid = metadata["securityKey"]["applicationId"] + metadata["securityKey"]["applicationId"] = None + with pytest.raises(exceptions.InvalidValue): + challenge._obtain_challenge_input_webauthn(metadata, mock_handler) + metadata["securityKey"]["applicationId"] = aid + + rpi = metadata["securityKey"]["relyingPartyId"] + metadata["securityKey"]["relyingPartyId"] = None + with pytest.raises(exceptions.InvalidValue): + challenge._obtain_challenge_input_webauthn(metadata, mock_handler) + metadata["securityKey"]["relyingPartyId"] = rpi + + kh = metadata["securityKey"]["challenges"][0]["keyHandle"] + metadata["securityKey"]["challenges"][0]["keyHandle"] = None + with pytest.raises(exceptions.InvalidValue): + challenge._obtain_challenge_input_webauthn(metadata, mock_handler) + metadata["securityKey"]["challenges"][0]["keyHandle"] = kh + + ch = metadata["securityKey"]["challenges"][0]["challenge"] + metadata["securityKey"]["challenges"][0]["challenge"] = None + with pytest.raises(exceptions.InvalidValue): + challenge._obtain_challenge_input_webauthn(metadata, mock_handler) + metadata["securityKey"]["challenges"][0]["challenge"] = ch + + # Handler Exceptions + mock_handler.get.side_effect = exceptions.MalformedError + with pytest.raises(exceptions.MalformedError): + challenge._obtain_challenge_input_webauthn(metadata, mock_handler) + + mock_handler.get.side_effect = exceptions.InvalidResource + with pytest.raises(exceptions.InvalidResource): + challenge._obtain_challenge_input_webauthn(metadata, mock_handler) + + mock_handler.get.side_effect = exceptions.ReauthFailError + with pytest.raises(exceptions.ReauthFailError): + challenge._obtain_challenge_input_webauthn(metadata, mock_handler) + + +@mock.patch("getpass.getpass", return_value="foo") +def test_password_challenge(getpass_mock): + challenge = challenges.PasswordChallenge() + + with mock.patch("getpass.getpass", return_value="foo"): + assert challenge.is_locally_eligible + assert challenge.name == "PASSWORD" + assert challenges.PasswordChallenge().obtain_challenge_input({}) == { + "credential": "foo" + } + + with mock.patch("getpass.getpass", return_value=None): + assert challenges.PasswordChallenge().obtain_challenge_input({}) == { + "credential": " " + } + + +def test_saml_challenge(): + challenge = challenges.SamlChallenge() + assert challenge.is_locally_eligible + assert challenge.name == "SAML" + with pytest.raises(exceptions.ReauthSamlChallengeFailError): + challenge.obtain_challenge_input(None) diff --git a/packages/google-auth/tests/oauth2/test_credentials.py b/packages/google-auth/tests/oauth2/test_credentials.py new file mode 100644 index 000000000000..7d2a9b8726c9 --- /dev/null +++ b/packages/google-auth/tests/oauth2/test_credentials.py @@ -0,0 +1,1068 @@ +# Copyright 2016 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 datetime +import json +import os +import pickle +import sys + +import mock +import pytest # type: ignore + +from google.auth import _helpers +from google.auth import exceptions +from google.auth import transport +from google.auth.credentials import TokenState +from google.oauth2 import credentials + + +DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "data") + +AUTH_USER_JSON_FILE = os.path.join(DATA_DIR, "authorized_user.json") + +with open(AUTH_USER_JSON_FILE, "r") as fh: + AUTH_USER_INFO = json.load(fh) + + +class TestCredentials(object): + TOKEN_URI = "https://example.com/oauth2/token" + REFRESH_TOKEN = "refresh_token" + RAPT_TOKEN = "rapt_token" + CLIENT_ID = "client_id" + CLIENT_SECRET = "client_secret" + + @classmethod + def make_credentials(cls): + return credentials.Credentials( + token=None, + refresh_token=cls.REFRESH_TOKEN, + token_uri=cls.TOKEN_URI, + client_id=cls.CLIENT_ID, + client_secret=cls.CLIENT_SECRET, + rapt_token=cls.RAPT_TOKEN, + enable_reauth_refresh=True, + ) + + def test_default_state(self): + credentials = self.make_credentials() + assert not credentials.valid + # Expiration hasn't been set yet + assert not credentials.expired + # Scopes aren't required for these credentials + assert not credentials.requires_scopes + assert credentials.token_state == TokenState.INVALID + # Test properties + assert credentials.refresh_token == self.REFRESH_TOKEN + assert credentials.token_uri == self.TOKEN_URI + assert credentials.client_id == self.CLIENT_ID + assert credentials.client_secret == self.CLIENT_SECRET + assert credentials.rapt_token == self.RAPT_TOKEN + assert credentials.refresh_handler is None + + def test_get_cred_info(self): + credentials = self.make_credentials() + credentials._account = "fake-account" + assert not credentials.get_cred_info() + + credentials._cred_file_path = "/path/to/file" + assert credentials.get_cred_info() == { + "credential_source": "/path/to/file", + "credential_type": "user credentials", + "principal": "fake-account", + } + + def test_get_cred_info_no_account(self): + credentials = self.make_credentials() + assert not credentials.get_cred_info() + + credentials._cred_file_path = "/path/to/file" + assert credentials.get_cred_info() == { + "credential_source": "/path/to/file", + "credential_type": "user credentials", + } + + def test__make_copy_get_cred_info(self): + credentials = self.make_credentials() + credentials._cred_file_path = "/path/to/file" + cred_copy = credentials._make_copy() + assert cred_copy._cred_file_path == "/path/to/file" + + def test_token_usage_metrics(self): + credentials = self.make_credentials() + credentials.token = "token" + credentials.expiry = None + + headers = {} + credentials.before_request(mock.Mock(), None, None, headers) + assert headers["authorization"] == "Bearer token" + assert headers["x-goog-api-client"] == "cred-type/u" + + def test_refresh_handler_setter_and_getter(self): + scopes = ["email", "profile"] + original_refresh_handler = mock.Mock(return_value=("ACCESS_TOKEN_1", None)) + updated_refresh_handler = mock.Mock(return_value=("ACCESS_TOKEN_2", None)) + creds = credentials.Credentials( + token=None, + refresh_token=None, + token_uri=None, + client_id=None, + client_secret=None, + rapt_token=None, + scopes=scopes, + default_scopes=None, + refresh_handler=original_refresh_handler, + ) + + assert creds.refresh_handler is original_refresh_handler + + creds.refresh_handler = updated_refresh_handler + + assert creds.refresh_handler is updated_refresh_handler + + creds.refresh_handler = None + + assert creds.refresh_handler is None + + def test_invalid_refresh_handler(self): + scopes = ["email", "profile"] + with pytest.raises(TypeError) as excinfo: + credentials.Credentials( + token=None, + refresh_token=None, + token_uri=None, + client_id=None, + client_secret=None, + rapt_token=None, + scopes=scopes, + default_scopes=None, + refresh_handler=object(), + ) + + assert excinfo.match("The provided refresh_handler is not a callable or None.") + + def test_refresh_with_non_default_universe_domain(self): + creds = credentials.Credentials( + token="token", universe_domain="dummy_universe.com" + ) + with pytest.raises(exceptions.RefreshError) as excinfo: + creds.refresh(mock.Mock()) + + assert excinfo.match( + "refresh is only supported in the default googleapis.com universe domain" + ) + + @mock.patch("google.oauth2.reauth.refresh_grant", autospec=True) + @mock.patch( + "google.auth._helpers.utcnow", + return_value=datetime.datetime.min + _helpers.REFRESH_THRESHOLD, + ) + def test_refresh_success(self, unused_utcnow, refresh_grant): + token = "token" + new_rapt_token = "new_rapt_token" + expiry = _helpers.utcnow() + datetime.timedelta(seconds=500) + grant_response = {"id_token": mock.sentinel.id_token} + refresh_grant.return_value = ( + # Access token + token, + # New refresh token + None, + # Expiry, + expiry, + # Extra data + grant_response, + # rapt_token + new_rapt_token, + ) + + request = mock.create_autospec(transport.Request) + credentials = self.make_credentials() + + # Refresh credentials + credentials.refresh(request) + + # Check jwt grant call. + refresh_grant.assert_called_with( + request, + self.TOKEN_URI, + self.REFRESH_TOKEN, + self.CLIENT_ID, + self.CLIENT_SECRET, + None, + self.RAPT_TOKEN, + True, + ) + + # Check that the credentials have the token and expiry + assert credentials.token == token + assert credentials.expiry == expiry + assert credentials.id_token == mock.sentinel.id_token + assert credentials.rapt_token == new_rapt_token + + # Check that the credentials are valid (have a token and are not + # expired) + assert credentials.valid + + def test_refresh_no_refresh_token(self): + request = mock.create_autospec(transport.Request) + credentials_ = credentials.Credentials(token=None, refresh_token=None) + + with pytest.raises(exceptions.RefreshError, match="necessary fields"): + credentials_.refresh(request) + + request.assert_not_called() + + @mock.patch("google.oauth2.reauth.refresh_grant", autospec=True) + @mock.patch( + "google.auth._helpers.utcnow", + return_value=datetime.datetime.min + _helpers.REFRESH_THRESHOLD, + ) + def test_refresh_with_refresh_token_and_refresh_handler( + self, unused_utcnow, refresh_grant + ): + token = "token" + new_rapt_token = "new_rapt_token" + expiry = _helpers.utcnow() + datetime.timedelta(seconds=500) + grant_response = {"id_token": mock.sentinel.id_token} + refresh_grant.return_value = ( + # Access token + token, + # New refresh token + None, + # Expiry, + expiry, + # Extra data + grant_response, + # rapt_token + new_rapt_token, + ) + + refresh_handler = mock.Mock() + request = mock.create_autospec(transport.Request) + creds = credentials.Credentials( + token=None, + refresh_token=self.REFRESH_TOKEN, + token_uri=self.TOKEN_URI, + client_id=self.CLIENT_ID, + client_secret=self.CLIENT_SECRET, + rapt_token=self.RAPT_TOKEN, + refresh_handler=refresh_handler, + ) + + # Refresh credentials + creds.refresh(request) + + # Check jwt grant call. + refresh_grant.assert_called_with( + request, + self.TOKEN_URI, + self.REFRESH_TOKEN, + self.CLIENT_ID, + self.CLIENT_SECRET, + None, + self.RAPT_TOKEN, + False, + ) + + # Check that the credentials have the token and expiry + assert creds.token == token + assert creds.expiry == expiry + assert creds.id_token == mock.sentinel.id_token + assert creds.rapt_token == new_rapt_token + + # Check that the credentials are valid (have a token and are not + # expired) + assert creds.valid + + # Assert refresh handler not called as the refresh token has + # higher priority. + refresh_handler.assert_not_called() + + @mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) + def test_refresh_with_refresh_handler_success_scopes(self, unused_utcnow): + expected_expiry = datetime.datetime.min + datetime.timedelta(seconds=2800) + refresh_handler = mock.Mock(return_value=("ACCESS_TOKEN", expected_expiry)) + scopes = ["email", "profile"] + default_scopes = ["https://www.googleapis.com/auth/cloud-platform"] + request = mock.create_autospec(transport.Request) + creds = credentials.Credentials( + token=None, + refresh_token=None, + token_uri=None, + client_id=None, + client_secret=None, + rapt_token=None, + scopes=scopes, + default_scopes=default_scopes, + refresh_handler=refresh_handler, + ) + + creds.refresh(request) + + assert creds.token == "ACCESS_TOKEN" + assert creds.expiry == expected_expiry + assert creds.valid + assert not creds.expired + # Confirm refresh handler called with the expected arguments. + refresh_handler.assert_called_with(request, scopes=scopes) + + @mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) + def test_refresh_with_refresh_handler_success_default_scopes(self, unused_utcnow): + expected_expiry = datetime.datetime.min + datetime.timedelta(seconds=2800) + original_refresh_handler = mock.Mock( + return_value=("UNUSED_TOKEN", expected_expiry) + ) + refresh_handler = mock.Mock(return_value=("ACCESS_TOKEN", expected_expiry)) + default_scopes = ["https://www.googleapis.com/auth/cloud-platform"] + request = mock.create_autospec(transport.Request) + creds = credentials.Credentials( + token=None, + refresh_token=None, + token_uri=None, + client_id=None, + client_secret=None, + rapt_token=None, + scopes=None, + default_scopes=default_scopes, + refresh_handler=original_refresh_handler, + ) + + # Test newly set refresh_handler is used instead of the original one. + creds.refresh_handler = refresh_handler + creds.refresh(request) + + assert creds.token == "ACCESS_TOKEN" + assert creds.expiry == expected_expiry + assert creds.valid + assert not creds.expired + # default_scopes should be used since no developer provided scopes + # are provided. + refresh_handler.assert_called_with(request, scopes=default_scopes) + + @mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) + def test_refresh_with_refresh_handler_invalid_token(self, unused_utcnow): + expected_expiry = datetime.datetime.min + datetime.timedelta(seconds=2800) + # Simulate refresh handler does not return a valid token. + refresh_handler = mock.Mock(return_value=(None, expected_expiry)) + scopes = ["email", "profile"] + default_scopes = ["https://www.googleapis.com/auth/cloud-platform"] + request = mock.create_autospec(transport.Request) + creds = credentials.Credentials( + token=None, + refresh_token=None, + token_uri=None, + client_id=None, + client_secret=None, + rapt_token=None, + scopes=scopes, + default_scopes=default_scopes, + refresh_handler=refresh_handler, + ) + + with pytest.raises( + exceptions.RefreshError, match="returned token is not a string" + ): + creds.refresh(request) + + assert creds.token is None + assert creds.expiry is None + assert not creds.valid + # Confirm refresh handler called with the expected arguments. + refresh_handler.assert_called_with(request, scopes=scopes) + + def test_refresh_with_refresh_handler_invalid_expiry(self): + # Simulate refresh handler returns expiration time in an invalid unit. + refresh_handler = mock.Mock(return_value=("TOKEN", 2800)) + scopes = ["email", "profile"] + default_scopes = ["https://www.googleapis.com/auth/cloud-platform"] + request = mock.create_autospec(transport.Request) + creds = credentials.Credentials( + token=None, + refresh_token=None, + token_uri=None, + client_id=None, + client_secret=None, + rapt_token=None, + scopes=scopes, + default_scopes=default_scopes, + refresh_handler=refresh_handler, + ) + + with pytest.raises( + exceptions.RefreshError, match="returned expiry is not a datetime object" + ): + creds.refresh(request) + + assert creds.token is None + assert creds.expiry is None + assert not creds.valid + # Confirm refresh handler called with the expected arguments. + refresh_handler.assert_called_with(request, scopes=scopes) + + @mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) + def test_refresh_with_refresh_handler_expired_token(self, unused_utcnow): + expected_expiry = datetime.datetime.min + _helpers.REFRESH_THRESHOLD + # Simulate refresh handler returns an expired token. + refresh_handler = mock.Mock(return_value=("TOKEN", expected_expiry)) + scopes = ["email", "profile"] + default_scopes = ["https://www.googleapis.com/auth/cloud-platform"] + request = mock.create_autospec(transport.Request) + creds = credentials.Credentials( + token=None, + refresh_token=None, + token_uri=None, + client_id=None, + client_secret=None, + rapt_token=None, + scopes=scopes, + default_scopes=default_scopes, + refresh_handler=refresh_handler, + ) + + with pytest.raises(exceptions.RefreshError, match="already expired"): + creds.refresh(request) + + assert creds.token is None + assert creds.expiry is None + assert not creds.valid + # Confirm refresh handler called with the expected arguments. + refresh_handler.assert_called_with(request, scopes=scopes) + + @mock.patch("google.oauth2.reauth.refresh_grant", autospec=True) + @mock.patch( + "google.auth._helpers.utcnow", + return_value=datetime.datetime.min + _helpers.REFRESH_THRESHOLD, + ) + def test_credentials_with_scopes_requested_refresh_success( + self, unused_utcnow, refresh_grant + ): + scopes = ["email", "profile"] + default_scopes = ["https://www.googleapis.com/auth/cloud-platform"] + token = "token" + new_rapt_token = "new_rapt_token" + expiry = _helpers.utcnow() + datetime.timedelta(seconds=500) + grant_response = {"id_token": mock.sentinel.id_token, "scope": "email profile"} + refresh_grant.return_value = ( + # Access token + token, + # New refresh token + None, + # Expiry, + expiry, + # Extra data + grant_response, + # rapt token + new_rapt_token, + ) + + request = mock.create_autospec(transport.Request) + creds = credentials.Credentials( + token=None, + refresh_token=self.REFRESH_TOKEN, + token_uri=self.TOKEN_URI, + client_id=self.CLIENT_ID, + client_secret=self.CLIENT_SECRET, + scopes=scopes, + default_scopes=default_scopes, + rapt_token=self.RAPT_TOKEN, + enable_reauth_refresh=True, + ) + + # Refresh credentials + creds.refresh(request) + + # Check jwt grant call. + refresh_grant.assert_called_with( + request, + self.TOKEN_URI, + self.REFRESH_TOKEN, + self.CLIENT_ID, + self.CLIENT_SECRET, + scopes, + self.RAPT_TOKEN, + True, + ) + + # Check that the credentials have the token and expiry + assert creds.token == token + assert creds.expiry == expiry + assert creds.id_token == mock.sentinel.id_token + assert creds.has_scopes(scopes) + assert creds.rapt_token == new_rapt_token + assert creds.granted_scopes == scopes + + # Check that the credentials are valid (have a token and are not + # expired.) + assert creds.valid + + @mock.patch("google.oauth2.reauth.refresh_grant", autospec=True) + @mock.patch( + "google.auth._helpers.utcnow", + return_value=datetime.datetime.min + _helpers.REFRESH_THRESHOLD, + ) + def test_credentials_with_only_default_scopes_requested( + self, unused_utcnow, refresh_grant + ): + default_scopes = ["email", "profile"] + token = "token" + new_rapt_token = "new_rapt_token" + expiry = _helpers.utcnow() + datetime.timedelta(seconds=500) + grant_response = {"id_token": mock.sentinel.id_token, "scope": "email profile"} + refresh_grant.return_value = ( + # Access token + token, + # New refresh token + None, + # Expiry, + expiry, + # Extra data + grant_response, + # rapt token + new_rapt_token, + ) + + request = mock.create_autospec(transport.Request) + creds = credentials.Credentials( + token=None, + refresh_token=self.REFRESH_TOKEN, + token_uri=self.TOKEN_URI, + client_id=self.CLIENT_ID, + client_secret=self.CLIENT_SECRET, + default_scopes=default_scopes, + rapt_token=self.RAPT_TOKEN, + enable_reauth_refresh=True, + ) + + # Refresh credentials + creds.refresh(request) + + # Check jwt grant call. + refresh_grant.assert_called_with( + request, + self.TOKEN_URI, + self.REFRESH_TOKEN, + self.CLIENT_ID, + self.CLIENT_SECRET, + default_scopes, + self.RAPT_TOKEN, + True, + ) + + # Check that the credentials have the token and expiry + assert creds.token == token + assert creds.expiry == expiry + assert creds.id_token == mock.sentinel.id_token + assert creds.has_scopes(default_scopes) + assert creds.rapt_token == new_rapt_token + assert creds.granted_scopes == default_scopes + + # Check that the credentials are valid (have a token and are not + # expired.) + assert creds.valid + + @mock.patch("google.oauth2.reauth.refresh_grant", autospec=True) + @mock.patch( + "google.auth._helpers.utcnow", + return_value=datetime.datetime.min + _helpers.REFRESH_THRESHOLD, + ) + def test_credentials_with_scopes_returned_refresh_success( + self, unused_utcnow, refresh_grant + ): + scopes = ["email", "profile"] + token = "token" + new_rapt_token = "new_rapt_token" + expiry = _helpers.utcnow() + datetime.timedelta(seconds=500) + grant_response = {"id_token": mock.sentinel.id_token, "scope": " ".join(scopes)} + refresh_grant.return_value = ( + # Access token + token, + # New refresh token + None, + # Expiry, + expiry, + # Extra data + grant_response, + # rapt token + new_rapt_token, + ) + + request = mock.create_autospec(transport.Request) + creds = credentials.Credentials( + token=None, + refresh_token=self.REFRESH_TOKEN, + token_uri=self.TOKEN_URI, + client_id=self.CLIENT_ID, + client_secret=self.CLIENT_SECRET, + scopes=scopes, + rapt_token=self.RAPT_TOKEN, + enable_reauth_refresh=True, + ) + + # Refresh credentials + creds.refresh(request) + + # Check jwt grant call. + refresh_grant.assert_called_with( + request, + self.TOKEN_URI, + self.REFRESH_TOKEN, + self.CLIENT_ID, + self.CLIENT_SECRET, + scopes, + self.RAPT_TOKEN, + True, + ) + + # Check that the credentials have the token and expiry + assert creds.token == token + assert creds.expiry == expiry + assert creds.id_token == mock.sentinel.id_token + assert creds.has_scopes(scopes) + assert creds.rapt_token == new_rapt_token + assert creds.granted_scopes == scopes + + # Check that the credentials are valid (have a token and are not + # expired.) + assert creds.valid + + @mock.patch("google.oauth2.reauth.refresh_grant", autospec=True) + @mock.patch( + "google.auth._helpers.utcnow", + return_value=datetime.datetime.min + _helpers.REFRESH_THRESHOLD, + ) + def test_credentials_with_only_default_scopes_requested_different_granted_scopes( + self, unused_utcnow, refresh_grant + ): + default_scopes = ["email", "profile"] + token = "token" + new_rapt_token = "new_rapt_token" + expiry = _helpers.utcnow() + datetime.timedelta(seconds=500) + grant_response = {"id_token": mock.sentinel.id_token, "scope": "email"} + refresh_grant.return_value = ( + # Access token + token, + # New refresh token + None, + # Expiry, + expiry, + # Extra data + grant_response, + # rapt token + new_rapt_token, + ) + + request = mock.create_autospec(transport.Request) + creds = credentials.Credentials( + token=None, + refresh_token=self.REFRESH_TOKEN, + token_uri=self.TOKEN_URI, + client_id=self.CLIENT_ID, + client_secret=self.CLIENT_SECRET, + default_scopes=default_scopes, + rapt_token=self.RAPT_TOKEN, + enable_reauth_refresh=True, + ) + + # Refresh credentials + creds.refresh(request) + + # Check jwt grant call. + refresh_grant.assert_called_with( + request, + self.TOKEN_URI, + self.REFRESH_TOKEN, + self.CLIENT_ID, + self.CLIENT_SECRET, + default_scopes, + self.RAPT_TOKEN, + True, + ) + + # Check that the credentials have the token and expiry + assert creds.token == token + assert creds.expiry == expiry + assert creds.id_token == mock.sentinel.id_token + assert creds.has_scopes(default_scopes) + assert creds.rapt_token == new_rapt_token + assert creds.granted_scopes == ["email"] + + # Check that the credentials are valid (have a token and are not + # expired.) + assert creds.valid + + @mock.patch("google.oauth2.reauth.refresh_grant", autospec=True) + @mock.patch( + "google.auth._helpers.utcnow", + return_value=datetime.datetime.min + _helpers.REFRESH_THRESHOLD, + ) + def test_credentials_with_scopes_refresh_different_granted_scopes( + self, unused_utcnow, refresh_grant + ): + scopes = ["email", "profile"] + scopes_returned = ["email"] + token = "token" + new_rapt_token = "new_rapt_token" + expiry = _helpers.utcnow() + datetime.timedelta(seconds=500) + grant_response = { + "id_token": mock.sentinel.id_token, + "scope": " ".join(scopes_returned), + } + refresh_grant.return_value = ( + # Access token + token, + # New refresh token + None, + # Expiry, + expiry, + # Extra data + grant_response, + # rapt token + new_rapt_token, + ) + + request = mock.create_autospec(transport.Request) + creds = credentials.Credentials( + token=None, + refresh_token=self.REFRESH_TOKEN, + token_uri=self.TOKEN_URI, + client_id=self.CLIENT_ID, + client_secret=self.CLIENT_SECRET, + scopes=scopes, + rapt_token=self.RAPT_TOKEN, + enable_reauth_refresh=True, + ) + + # Refresh credentials + creds.refresh(request) + + # Check jwt grant call. + refresh_grant.assert_called_with( + request, + self.TOKEN_URI, + self.REFRESH_TOKEN, + self.CLIENT_ID, + self.CLIENT_SECRET, + scopes, + self.RAPT_TOKEN, + True, + ) + + # Check that the credentials have the token and expiry + assert creds.token == token + assert creds.expiry == expiry + assert creds.id_token == mock.sentinel.id_token + assert creds.has_scopes(scopes) + assert creds.rapt_token == new_rapt_token + assert creds.granted_scopes == scopes_returned + + # Check that the credentials are valid (have a token and are not + # expired.) + assert creds.valid + + def test_apply_with_quota_project_id(self): + creds = credentials.Credentials( + token="token", + refresh_token=self.REFRESH_TOKEN, + token_uri=self.TOKEN_URI, + client_id=self.CLIENT_ID, + client_secret=self.CLIENT_SECRET, + quota_project_id="quota-project-123", + ) + + headers = {} + creds.apply(headers) + assert headers["x-goog-user-project"] == "quota-project-123" + assert "token" in headers["authorization"] + + def test_apply_with_no_quota_project_id(self): + creds = credentials.Credentials( + token="token", + refresh_token=self.REFRESH_TOKEN, + token_uri=self.TOKEN_URI, + client_id=self.CLIENT_ID, + client_secret=self.CLIENT_SECRET, + ) + + headers = {} + creds.apply(headers) + assert "x-goog-user-project" not in headers + assert "token" in headers["authorization"] + + def test_with_quota_project(self): + creds = credentials.Credentials( + token="token", + refresh_token=self.REFRESH_TOKEN, + token_uri=self.TOKEN_URI, + client_id=self.CLIENT_ID, + client_secret=self.CLIENT_SECRET, + quota_project_id="quota-project-123", + ) + + new_creds = creds.with_quota_project("new-project-456") + assert new_creds.quota_project_id == "new-project-456" + headers = {} + creds.apply(headers) + assert "x-goog-user-project" in headers + + def test_with_universe_domain(self): + creds = credentials.Credentials(token="token") + assert creds.universe_domain == "googleapis.com" + new_creds = creds.with_universe_domain("dummy_universe.com") + assert new_creds.universe_domain == "dummy_universe.com" + + def test_with_account(self): + creds = credentials.Credentials(token="token") + assert creds.account == "" + new_creds = creds.with_account("mock@example.com") + assert new_creds.account == "mock@example.com" + + def test_with_token_uri(self): + info = AUTH_USER_INFO.copy() + + creds = credentials.Credentials.from_authorized_user_info(info) + new_token_uri = "https://oauth2-eu.googleapis.com/token" + + assert creds._token_uri == credentials._GOOGLE_OAUTH2_TOKEN_ENDPOINT + + creds_with_new_token_uri = creds.with_token_uri(new_token_uri) + + assert creds_with_new_token_uri._token_uri == new_token_uri + + def test_from_authorized_user_info(self): + info = AUTH_USER_INFO.copy() + + creds = credentials.Credentials.from_authorized_user_info(info) + assert creds.client_secret == info["client_secret"] + assert creds.client_id == info["client_id"] + assert creds.refresh_token == info["refresh_token"] + assert creds.token_uri == credentials._GOOGLE_OAUTH2_TOKEN_ENDPOINT + assert creds.scopes is None + + scopes = ["email", "profile"] + creds = credentials.Credentials.from_authorized_user_info(info, scopes) + assert creds.client_secret == info["client_secret"] + assert creds.client_id == info["client_id"] + assert creds.refresh_token == info["refresh_token"] + assert creds.token_uri == credentials._GOOGLE_OAUTH2_TOKEN_ENDPOINT + assert creds.scopes == scopes + + info["scopes"] = "email" # single non-array scope from file + creds = credentials.Credentials.from_authorized_user_info(info) + assert creds.scopes == [info["scopes"]] + + info["scopes"] = ["email", "profile"] # array scope from file + creds = credentials.Credentials.from_authorized_user_info(info) + assert creds.scopes == info["scopes"] + + expiry = datetime.datetime(2020, 8, 14, 15, 54, 1) + info["expiry"] = expiry.isoformat() + "Z" + creds = credentials.Credentials.from_authorized_user_info(info) + assert creds.expiry == expiry + assert creds.expired + + def test_from_authorized_user_file(self): + info = AUTH_USER_INFO.copy() + + creds = credentials.Credentials.from_authorized_user_file(AUTH_USER_JSON_FILE) + assert creds.client_secret == info["client_secret"] + assert creds.client_id == info["client_id"] + assert creds.refresh_token == info["refresh_token"] + assert creds.token_uri == credentials._GOOGLE_OAUTH2_TOKEN_ENDPOINT + assert creds.scopes is None + assert creds.rapt_token is None + + scopes = ["email", "profile"] + creds = credentials.Credentials.from_authorized_user_file( + AUTH_USER_JSON_FILE, scopes + ) + assert creds.client_secret == info["client_secret"] + assert creds.client_id == info["client_id"] + assert creds.refresh_token == info["refresh_token"] + assert creds.token_uri == credentials._GOOGLE_OAUTH2_TOKEN_ENDPOINT + assert creds.scopes == scopes + + def test_from_authorized_user_file_with_rapt_token(self): + info = AUTH_USER_INFO.copy() + file_path = os.path.join(DATA_DIR, "authorized_user_with_rapt_token.json") + + creds = credentials.Credentials.from_authorized_user_file(file_path) + assert creds.client_secret == info["client_secret"] + assert creds.client_id == info["client_id"] + assert creds.refresh_token == info["refresh_token"] + assert creds.token_uri == credentials._GOOGLE_OAUTH2_TOKEN_ENDPOINT + assert creds.scopes is None + assert creds.rapt_token == "rapt" + + def test_to_json(self): + info = AUTH_USER_INFO.copy() + expiry = datetime.datetime(2020, 8, 14, 15, 54, 1) + info["expiry"] = expiry.isoformat() + "Z" + creds = credentials.Credentials.from_authorized_user_info(info) + assert creds.expiry == expiry + + # Test with no `strip` arg + json_output = creds.to_json() + json_asdict = json.loads(json_output) + assert json_asdict.get("token") == creds.token + assert json_asdict.get("refresh_token") == creds.refresh_token + assert json_asdict.get("token_uri") == creds.token_uri + assert json_asdict.get("client_id") == creds.client_id + assert json_asdict.get("scopes") == creds.scopes + assert json_asdict.get("client_secret") == creds.client_secret + assert json_asdict.get("expiry") == info["expiry"] + assert json_asdict.get("universe_domain") == creds.universe_domain + assert json_asdict.get("account") == creds.account + + # Test with a `strip` arg + json_output = creds.to_json(strip=["client_secret"]) + json_asdict = json.loads(json_output) + assert json_asdict.get("token") == creds.token + assert json_asdict.get("refresh_token") == creds.refresh_token + assert json_asdict.get("token_uri") == creds.token_uri + assert json_asdict.get("client_id") == creds.client_id + assert json_asdict.get("scopes") == creds.scopes + assert json_asdict.get("client_secret") is None + + # Test with no expiry + creds.expiry = None + json_output = creds.to_json() + json_asdict = json.loads(json_output) + assert json_asdict.get("expiry") is None + + def test_pickle_and_unpickle(self): + creds = self.make_credentials() + unpickled = pickle.loads(pickle.dumps(creds)) + + # make sure attributes aren't lost during pickling + assert list(creds.__dict__).sort() == list(unpickled.__dict__).sort() + + for attr in list(creds.__dict__): + # Worker should always be None + if attr == "_refresh_worker": + assert getattr(unpickled, attr) is None + else: + assert getattr(creds, attr) == getattr(unpickled, attr) + + def test_pickle_and_unpickle_universe_domain(self): + # old version of auth lib doesn't have _universe_domain, so the pickled + # cred doesn't have such a field. + creds = self.make_credentials() + del creds._universe_domain + + unpickled = pickle.loads(pickle.dumps(creds)) + + # make sure the unpickled cred sets _universe_domain to default. + assert unpickled.universe_domain == "googleapis.com" + + def test_pickle_and_unpickle_with_refresh_handler(self): + expected_expiry = _helpers.utcnow() + datetime.timedelta(seconds=2800) + refresh_handler = mock.Mock(return_value=("TOKEN", expected_expiry)) + + creds = credentials.Credentials( + token=None, + refresh_token=None, + token_uri=None, + client_id=None, + client_secret=None, + rapt_token=None, + refresh_handler=refresh_handler, + ) + unpickled = pickle.loads(pickle.dumps(creds)) + + # make sure attributes aren't lost during pickling + assert list(creds.__dict__).sort() == list(unpickled.__dict__).sort() + + for attr in list(creds.__dict__): + # For the _refresh_handler property, the unpickled creds should be + # set to None. + if attr == "_refresh_handler" or attr == "_refresh_worker": + assert getattr(unpickled, attr) is None + else: + assert getattr(creds, attr) == getattr(unpickled, attr) + + def test_pickle_with_missing_attribute(self): + creds = self.make_credentials() + + # remove an optional attribute before pickling + # this mimics a pickle created with a previous class definition with + # fewer attributes + del creds.__dict__["_quota_project_id"] + del creds.__dict__["_refresh_handler"] + del creds.__dict__["_refresh_worker"] + + unpickled = pickle.loads(pickle.dumps(creds)) + + # Attribute should be initialized by `__setstate__` + assert unpickled.quota_project_id is None + + # pickles are not compatible across versions + @pytest.mark.skipif( + sys.version_info < (3, 5), + reason="pickle file can only be loaded with Python >= 3.5", + ) + def test_unpickle_old_credentials_pickle(self): + # make sure a credentials file pickled with an older + # library version (google-auth==1.5.1) can be unpickled + with open( + os.path.join(DATA_DIR, "old_oauth_credentials_py3.pickle"), "rb" + ) as f: + credentials = pickle.load(f) + assert credentials.quota_project_id is None + + +class TestUserAccessTokenCredentials(object): + def test_instance(self): + with pytest.warns( + UserWarning, match="UserAccessTokenCredentials is deprecated" + ): + cred = credentials.UserAccessTokenCredentials() + assert cred._account is None + + cred = cred.with_account("account") + assert cred._account == "account" + + @mock.patch("google.auth._cloud_sdk.get_auth_access_token", autospec=True) + def test_refresh(self, get_auth_access_token): + with pytest.warns( + UserWarning, match="UserAccessTokenCredentials is deprecated" + ): + get_auth_access_token.return_value = "access_token" + cred = credentials.UserAccessTokenCredentials() + cred.refresh(None) + assert cred.token == "access_token" + + def test_with_quota_project(self): + with pytest.warns( + UserWarning, match="UserAccessTokenCredentials is deprecated" + ): + cred = credentials.UserAccessTokenCredentials() + quota_project_cred = cred.with_quota_project("project-foo") + + assert quota_project_cred._quota_project_id == "project-foo" + assert quota_project_cred._account == cred._account + + @mock.patch( + "google.oauth2.credentials.UserAccessTokenCredentials.apply", autospec=True + ) + @mock.patch( + "google.oauth2.credentials.UserAccessTokenCredentials.refresh", autospec=True + ) + def test_before_request(self, refresh, apply): + with pytest.warns( + UserWarning, match="UserAccessTokenCredentials is deprecated" + ): + cred = credentials.UserAccessTokenCredentials() + cred.before_request(mock.Mock(), "GET", "https://example.com", {}) + refresh.assert_called() + apply.assert_called() diff --git a/packages/google-auth/tests/oauth2/test_gdch_credentials.py b/packages/google-auth/tests/oauth2/test_gdch_credentials.py new file mode 100644 index 000000000000..63075aba03eb --- /dev/null +++ b/packages/google-auth/tests/oauth2/test_gdch_credentials.py @@ -0,0 +1,173 @@ +# 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. + +import copy +import datetime +import json +import os + +import mock +import pytest # type: ignore +import requests + +from google.auth import exceptions +from google.auth import jwt +import google.auth.transport.requests +from google.oauth2 import gdch_credentials +from google.oauth2.gdch_credentials import ServiceAccountCredentials + + +class TestServiceAccountCredentials(object): + AUDIENCE = "https://service-identity./authenticate" + PROJECT = "project_foo" + PRIVATE_KEY_ID = "key_foo" + NAME = "service_identity_name" + CA_CERT_PATH = "/path/to/ca/cert" + TOKEN_URI = "https://service-identity./authenticate" + + JSON_PATH = os.path.join( + os.path.dirname(__file__), "..", "data", "gdch_service_account.json" + ) + with open(JSON_PATH, "rb") as fh: + INFO = json.load(fh) + + def test_with_gdch_audience(self): + mock_signer = mock.Mock() + creds = ServiceAccountCredentials._from_signer_and_info(mock_signer, self.INFO) + assert creds._signer == mock_signer + assert creds._service_identity_name == self.NAME + assert creds._audience is None + assert creds._token_uri == self.TOKEN_URI + assert creds._ca_cert_path == self.CA_CERT_PATH + + new_creds = creds.with_gdch_audience(self.AUDIENCE) + assert new_creds._signer == mock_signer + assert new_creds._service_identity_name == self.NAME + assert new_creds._audience == self.AUDIENCE + assert new_creds._token_uri == self.TOKEN_URI + assert new_creds._ca_cert_path == self.CA_CERT_PATH + + def test__create_jwt(self): + creds = ServiceAccountCredentials.from_service_account_file(self.JSON_PATH) + with mock.patch("google.auth._helpers.utcnow") as utcnow: + utcnow.return_value = datetime.datetime.now() + jwt_token = creds._create_jwt() + header, payload, _, _ = jwt._unverified_decode(jwt_token) + + expected_iss_sub_value = ( + "system:serviceaccount:project_foo:service_identity_name" + ) + assert isinstance(jwt_token, str) + assert header["alg"] == "ES256" + assert header["kid"] == self.PRIVATE_KEY_ID + assert payload["iss"] == expected_iss_sub_value + assert payload["sub"] == expected_iss_sub_value + assert payload["aud"] == self.AUDIENCE + assert payload["exp"] == (payload["iat"] + 3600) + + @mock.patch( + "google.oauth2.gdch_credentials.ServiceAccountCredentials._create_jwt", + autospec=True, + ) + @mock.patch("google.oauth2._client._token_endpoint_request", autospec=True) + def test_refresh(self, token_endpoint_request, create_jwt): + creds = ServiceAccountCredentials.from_service_account_info(self.INFO) + creds = creds.with_gdch_audience(self.AUDIENCE) + req = google.auth.transport.requests.Request() + + mock_jwt_token = "jwt token" + create_jwt.return_value = mock_jwt_token + sts_token = "STS token" + token_endpoint_request.return_value = { + "access_token": sts_token, + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "Bearer", + "expires_in": 3600, + } + + creds.refresh(req) + + token_endpoint_request.assert_called_with( + req, + self.TOKEN_URI, + { + "grant_type": gdch_credentials.TOKEN_EXCHANGE_TYPE, + "audience": self.AUDIENCE, + "requested_token_type": gdch_credentials.ACCESS_TOKEN_TOKEN_TYPE, + "subject_token": mock_jwt_token, + "subject_token_type": gdch_credentials.SERVICE_ACCOUNT_TOKEN_TYPE, + }, + access_token=None, + use_json=True, + verify=self.CA_CERT_PATH, + ) + assert creds.token == sts_token + + def test_refresh_wrong_requests_object(self): + creds = ServiceAccountCredentials.from_service_account_info(self.INFO) + creds = creds.with_gdch_audience(self.AUDIENCE) + req = requests.Request() + + with pytest.raises(exceptions.RefreshError) as excinfo: + creds.refresh(req) + assert excinfo.match( + "request must be a google.auth.transport.requests.Request object" + ) + + def test__from_signer_and_info_wrong_format_version(self): + with pytest.raises(ValueError) as excinfo: + ServiceAccountCredentials._from_signer_and_info( + mock.Mock(), {"format_version": "2"} + ) + assert excinfo.match("Only format version 1 is supported") + + def test_from_service_account_info_miss_field(self): + for field in [ + "format_version", + "private_key_id", + "private_key", + "name", + "project", + "token_uri", + ]: + info_with_missing_field = copy.deepcopy(self.INFO) + del info_with_missing_field[field] + with pytest.raises(ValueError) as excinfo: + ServiceAccountCredentials.from_service_account_info( + info_with_missing_field + ) + assert excinfo.match("missing fields") + + @mock.patch("google.auth._service_account_info.from_filename") + def test_from_service_account_file(self, from_filename): + mock_signer = mock.Mock() + from_filename.return_value = (self.INFO, mock_signer) + creds = ServiceAccountCredentials.from_service_account_file(self.JSON_PATH) + from_filename.assert_called_with( + self.JSON_PATH, + require=[ + "format_version", + "private_key_id", + "private_key", + "name", + "project", + "token_uri", + ], + use_rsa_signer=False, + ) + assert creds._signer == mock_signer + assert creds._service_identity_name == self.NAME + assert creds._audience is None + assert creds._token_uri == self.TOKEN_URI + assert creds._ca_cert_path == self.CA_CERT_PATH diff --git a/packages/google-auth/tests/oauth2/test_id_token.py b/packages/google-auth/tests/oauth2/test_id_token.py new file mode 100644 index 000000000000..ff3d4b6d8315 --- /dev/null +++ b/packages/google-auth/tests/oauth2/test_id_token.py @@ -0,0 +1,349 @@ +# Copyright 2014 Google Inc. +# +# 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 json +import os + +import mock +import pytest # type: ignore + +from google.auth import environment_vars +from google.auth import exceptions +from google.auth import impersonated_credentials +from google.auth import transport +from google.oauth2 import id_token +from google.oauth2 import service_account + +SERVICE_ACCOUNT_FILE = os.path.join( + os.path.dirname(__file__), "../data/service_account.json" +) + +IMPERSONATED_SERVICE_ACCOUNT_FILE = os.path.join( + os.path.dirname(__file__), + "../data/impersonated_service_account_authorized_user_source.json", +) + +ID_TOKEN_AUDIENCE = "https://pubsub.googleapis.com" + + +def make_request(status, data=None): + response = mock.create_autospec(transport.Response, instance=True) + response.status = status + + if data is not None: + response.data = json.dumps(data).encode("utf-8") + + request = mock.create_autospec(transport.Request) + request.return_value = response + return request + + +def test__fetch_certs_success(): + certs = {"1": "cert"} + request = make_request(200, certs) + + returned_certs = id_token._fetch_certs(request, mock.sentinel.cert_url) + + request.assert_called_once_with(mock.sentinel.cert_url, method="GET") + assert returned_certs == certs + + +def test__fetch_certs_failure(): + request = make_request(404) + + with pytest.raises(exceptions.TransportError): + id_token._fetch_certs(request, mock.sentinel.cert_url) + + request.assert_called_once_with(mock.sentinel.cert_url, method="GET") + + +@mock.patch("google.auth.jwt.decode", autospec=True) +@mock.patch("google.oauth2.id_token._fetch_certs", autospec=True) +def test_verify_token(_fetch_certs, decode): + result = id_token.verify_token(mock.sentinel.token, mock.sentinel.request) + + assert result == decode.return_value + _fetch_certs.assert_called_once_with( + mock.sentinel.request, id_token._GOOGLE_OAUTH2_CERTS_URL + ) + decode.assert_called_once_with( + mock.sentinel.token, + certs=_fetch_certs.return_value, + audience=None, + clock_skew_in_seconds=0, + ) + + +@mock.patch("google.oauth2.id_token._fetch_certs", autospec=True) +@mock.patch("jwt.PyJWKClient", autospec=True) +@mock.patch("jwt.decode", autospec=True) +def test_verify_token_jwk(decode, py_jwk, _fetch_certs): + certs_url = "abc123" + data = {"keys": [{"alg": "RS256"}]} + _fetch_certs.return_value = data + result = id_token.verify_token( + mock.sentinel.token, mock.sentinel.request, certs_url=certs_url + ) + assert result == decode.return_value + py_jwk.assert_called_once_with(certs_url) + signing_key = py_jwk.return_value.get_signing_key_from_jwt + _fetch_certs.assert_called_once_with(mock.sentinel.request, certs_url) + signing_key.assert_called_once_with(mock.sentinel.token) + decode.assert_called_once_with( + mock.sentinel.token, + signing_key.return_value.key, + algorithms=[signing_key.return_value.algorithm_name], + audience=None, + ) + + +@mock.patch("google.auth.jwt.decode", autospec=True) +@mock.patch("google.oauth2.id_token._fetch_certs", autospec=True) +def test_verify_token_args(_fetch_certs, decode): + result = id_token.verify_token( + mock.sentinel.token, + mock.sentinel.request, + audience=mock.sentinel.audience, + certs_url=mock.sentinel.certs_url, + ) + + assert result == decode.return_value + _fetch_certs.assert_called_once_with(mock.sentinel.request, mock.sentinel.certs_url) + decode.assert_called_once_with( + mock.sentinel.token, + certs=_fetch_certs.return_value, + audience=mock.sentinel.audience, + clock_skew_in_seconds=0, + ) + + +@mock.patch("google.auth.jwt.decode", autospec=True) +@mock.patch("google.oauth2.id_token._fetch_certs", autospec=True) +def test_verify_token_clock_skew(_fetch_certs, decode): + result = id_token.verify_token( + mock.sentinel.token, + mock.sentinel.request, + audience=mock.sentinel.audience, + certs_url=mock.sentinel.certs_url, + clock_skew_in_seconds=10, + ) + + assert result == decode.return_value + _fetch_certs.assert_called_once_with(mock.sentinel.request, mock.sentinel.certs_url) + decode.assert_called_once_with( + mock.sentinel.token, + certs=_fetch_certs.return_value, + audience=mock.sentinel.audience, + clock_skew_in_seconds=10, + ) + + +@mock.patch("google.oauth2.id_token.verify_token", autospec=True) +def test_verify_oauth2_token(verify_token): + verify_token.return_value = {"iss": "accounts.google.com"} + result = id_token.verify_oauth2_token( + mock.sentinel.token, mock.sentinel.request, audience=mock.sentinel.audience + ) + + assert result == verify_token.return_value + verify_token.assert_called_once_with( + mock.sentinel.token, + mock.sentinel.request, + audience=mock.sentinel.audience, + certs_url=id_token._GOOGLE_OAUTH2_CERTS_URL, + clock_skew_in_seconds=0, + ) + + +@mock.patch("google.oauth2.id_token.verify_token", autospec=True) +def test_verify_oauth2_token_clock_skew(verify_token): + verify_token.return_value = {"iss": "accounts.google.com"} + result = id_token.verify_oauth2_token( + mock.sentinel.token, + mock.sentinel.request, + audience=mock.sentinel.audience, + clock_skew_in_seconds=10, + ) + + assert result == verify_token.return_value + verify_token.assert_called_once_with( + mock.sentinel.token, + mock.sentinel.request, + audience=mock.sentinel.audience, + certs_url=id_token._GOOGLE_OAUTH2_CERTS_URL, + clock_skew_in_seconds=10, + ) + + +@mock.patch("google.oauth2.id_token.verify_token", autospec=True) +def test_verify_oauth2_token_invalid_iss(verify_token): + verify_token.return_value = {"iss": "invalid_issuer"} + + with pytest.raises(exceptions.GoogleAuthError): + id_token.verify_oauth2_token( + mock.sentinel.token, mock.sentinel.request, audience=mock.sentinel.audience + ) + + +@mock.patch("google.oauth2.id_token.verify_token", autospec=True) +def test_verify_firebase_token(verify_token): + result = id_token.verify_firebase_token( + mock.sentinel.token, mock.sentinel.request, audience=mock.sentinel.audience + ) + + assert result == verify_token.return_value + verify_token.assert_called_once_with( + mock.sentinel.token, + mock.sentinel.request, + audience=mock.sentinel.audience, + certs_url=id_token._GOOGLE_APIS_CERTS_URL, + clock_skew_in_seconds=0, + ) + + +@mock.patch("google.oauth2.id_token.verify_token", autospec=True) +def test_verify_firebase_token_clock_skew(verify_token): + result = id_token.verify_firebase_token( + mock.sentinel.token, + mock.sentinel.request, + audience=mock.sentinel.audience, + clock_skew_in_seconds=10, + ) + + assert result == verify_token.return_value + verify_token.assert_called_once_with( + mock.sentinel.token, + mock.sentinel.request, + audience=mock.sentinel.audience, + certs_url=id_token._GOOGLE_APIS_CERTS_URL, + clock_skew_in_seconds=10, + ) + + +def test_fetch_id_token_credentials_optional_request(monkeypatch): + monkeypatch.delenv(environment_vars.CREDENTIALS, raising=False) + + # Test a request object is created if not provided + with mock.patch("google.auth.compute_engine._metadata.ping", return_value=True): + with mock.patch( + "google.auth.compute_engine.IDTokenCredentials.__init__", return_value=None + ): + with mock.patch( + "google.auth.transport.requests.Request.__init__", return_value=None + ) as mock_request: + id_token.fetch_id_token_credentials(ID_TOKEN_AUDIENCE) + mock_request.assert_called() + + +def test_fetch_id_token_credentials_from_metadata_server(monkeypatch): + monkeypatch.delenv(environment_vars.CREDENTIALS, raising=False) + + mock_req = mock.Mock() + + with mock.patch("google.auth.compute_engine._metadata.ping", return_value=True): + with mock.patch( + "google.auth.compute_engine.IDTokenCredentials.__init__", return_value=None + ) as mock_init: + id_token.fetch_id_token_credentials(ID_TOKEN_AUDIENCE, request=mock_req) + mock_init.assert_called_once_with( + mock_req, ID_TOKEN_AUDIENCE, use_metadata_identity_endpoint=True + ) + + +def test_fetch_id_token_credentials_from_explicit_cred_json_file(monkeypatch): + monkeypatch.setenv(environment_vars.CREDENTIALS, SERVICE_ACCOUNT_FILE) + + cred = id_token.fetch_id_token_credentials(ID_TOKEN_AUDIENCE) + assert isinstance(cred, service_account.IDTokenCredentials) + assert cred._target_audience == ID_TOKEN_AUDIENCE + + +def test_fetch_id_token_credentials_from_impersonated_cred_json_file(monkeypatch): + monkeypatch.setenv(environment_vars.CREDENTIALS, IMPERSONATED_SERVICE_ACCOUNT_FILE) + + cred = id_token.fetch_id_token_credentials(ID_TOKEN_AUDIENCE) + assert isinstance(cred, impersonated_credentials.IDTokenCredentials) + assert cred._target_audience == ID_TOKEN_AUDIENCE + + +def test_fetch_id_token_credentials_no_cred_exists(monkeypatch): + monkeypatch.delenv(environment_vars.CREDENTIALS, raising=False) + + with mock.patch( + "google.auth.compute_engine._metadata.ping", + side_effect=exceptions.TransportError(), + ): + with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: + id_token.fetch_id_token_credentials(ID_TOKEN_AUDIENCE) + assert excinfo.match( + r"Neither metadata server or valid service account credentials are found." + ) + + with mock.patch("google.auth.compute_engine._metadata.ping", return_value=False): + with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: + id_token.fetch_id_token_credentials(ID_TOKEN_AUDIENCE) + assert excinfo.match( + r"Neither metadata server or valid service account credentials are found." + ) + + +def test_fetch_id_token_credentials_invalid_cred_file_type(monkeypatch): + user_credentials_file = os.path.join( + os.path.dirname(__file__), "../data/authorized_user.json" + ) + monkeypatch.setenv(environment_vars.CREDENTIALS, user_credentials_file) + + with mock.patch("google.auth.compute_engine._metadata.ping", return_value=False): + with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: + id_token.fetch_id_token_credentials(ID_TOKEN_AUDIENCE) + assert excinfo.match( + r"Neither metadata server or valid service account credentials are found." + ) + + +def test_fetch_id_token_credentials_invalid_json(monkeypatch): + 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) as excinfo: + id_token.fetch_id_token_credentials(ID_TOKEN_AUDIENCE) + assert excinfo.match( + r"GOOGLE_APPLICATION_CREDENTIALS is not valid service account credentials." + ) + + +def test_fetch_id_token_credentials_invalid_cred_path(monkeypatch): + not_json_file = os.path.join(os.path.dirname(__file__), "../data/not_exists.json") + monkeypatch.setenv(environment_vars.CREDENTIALS, not_json_file) + + with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: + id_token.fetch_id_token_credentials(ID_TOKEN_AUDIENCE) + assert excinfo.match( + r"GOOGLE_APPLICATION_CREDENTIALS path is either not found or invalid." + ) + + +def test_fetch_id_token(monkeypatch): + mock_cred = mock.MagicMock() + mock_cred.token = "token" + + mock_req = mock.Mock() + + with mock.patch( + "google.oauth2.id_token.fetch_id_token_credentials", return_value=mock_cred + ) as mock_fetch: + token = id_token.fetch_id_token(mock_req, ID_TOKEN_AUDIENCE) + mock_fetch.assert_called_once_with(ID_TOKEN_AUDIENCE, request=mock_req) + mock_cred.refresh.assert_called_once_with(mock_req) + assert token == "token" diff --git a/packages/google-auth/tests/oauth2/test_reauth.py b/packages/google-auth/tests/oauth2/test_reauth.py new file mode 100644 index 000000000000..a95367a2b2f6 --- /dev/null +++ b/packages/google-auth/tests/oauth2/test_reauth.py @@ -0,0 +1,388 @@ +# Copyright 2021 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 copy + +import mock +import pytest # type: ignore + +from google.auth import exceptions +from google.oauth2 import reauth + + +MOCK_REQUEST = mock.Mock() +CHALLENGES_RESPONSE_TEMPLATE = { + "status": "CHALLENGE_REQUIRED", + "sessionId": "123", + "challenges": [ + { + "status": "READY", + "challengeId": 1, + "challengeType": "PASSWORD", + "securityKey": {}, + } + ], +} +CHALLENGES_RESPONSE_AUTHENTICATED = { + "status": "AUTHENTICATED", + "sessionId": "123", + "encodedProofOfReauthToken": "new_rapt_token", +} + +REAUTH_START_METRICS_HEADER_VALUE = "gl-python/3.7 auth/1.1 auth-request-type/re-start" +REAUTH_CONTINUE_METRICS_HEADER_VALUE = ( + "gl-python/3.7 auth/1.1 auth-request-type/re-cont" +) +TOKEN_REQUEST_METRICS_HEADER_VALUE = "gl-python/3.7 auth/1.1 cred-type/u" + + +class MockChallenge(object): + def __init__(self, name, locally_eligible, challenge_input): + self.name = name + self.is_locally_eligible = locally_eligible + self.challenge_input = challenge_input + + def obtain_challenge_input(self, metadata): + return self.challenge_input + + +def test_is_interactive(): + with mock.patch("sys.stdin.isatty", return_value=True): + assert reauth.is_interactive() + + +@mock.patch( + "google.auth.metrics.reauth_start", return_value=REAUTH_START_METRICS_HEADER_VALUE +) +def test__get_challenges(mock_metrics_header_value): + with mock.patch( + "google.oauth2._client._token_endpoint_request" + ) as mock_token_endpoint_request: + reauth._get_challenges(MOCK_REQUEST, ["SAML"], "token") + mock_token_endpoint_request.assert_called_with( + MOCK_REQUEST, + reauth._REAUTH_API + ":start", + {"supportedChallengeTypes": ["SAML"]}, + access_token="token", + use_json=True, + headers={"x-goog-api-client": REAUTH_START_METRICS_HEADER_VALUE}, + ) + + +@mock.patch( + "google.auth.metrics.reauth_start", return_value=REAUTH_START_METRICS_HEADER_VALUE +) +def test__get_challenges_with_scopes(mock_metrics_header_value): + with mock.patch( + "google.oauth2._client._token_endpoint_request" + ) as mock_token_endpoint_request: + reauth._get_challenges( + MOCK_REQUEST, ["SAML"], "token", requested_scopes=["scope"] + ) + mock_token_endpoint_request.assert_called_with( + MOCK_REQUEST, + reauth._REAUTH_API + ":start", + { + "supportedChallengeTypes": ["SAML"], + "oauthScopesForDomainPolicyLookup": ["scope"], + }, + access_token="token", + use_json=True, + headers={"x-goog-api-client": REAUTH_START_METRICS_HEADER_VALUE}, + ) + + +@mock.patch( + "google.auth.metrics.reauth_continue", + return_value=REAUTH_CONTINUE_METRICS_HEADER_VALUE, +) +def test__send_challenge_result(mock_metrics_header_value): + with mock.patch( + "google.oauth2._client._token_endpoint_request" + ) as mock_token_endpoint_request: + reauth._send_challenge_result( + MOCK_REQUEST, "123", "1", {"credential": "password"}, "token" + ) + mock_token_endpoint_request.assert_called_with( + MOCK_REQUEST, + reauth._REAUTH_API + "/123:continue", + { + "sessionId": "123", + "challengeId": "1", + "action": "RESPOND", + "proposalResponse": {"credential": "password"}, + }, + access_token="token", + use_json=True, + headers={"x-goog-api-client": REAUTH_CONTINUE_METRICS_HEADER_VALUE}, + ) + + +def test__run_next_challenge_not_ready(): + challenges_response = copy.deepcopy(CHALLENGES_RESPONSE_TEMPLATE) + challenges_response["challenges"][0]["status"] = "STATUS_UNSPECIFIED" + assert ( + reauth._run_next_challenge(challenges_response, MOCK_REQUEST, "token") is None + ) + + +def test__run_next_challenge_not_supported(): + challenges_response = copy.deepcopy(CHALLENGES_RESPONSE_TEMPLATE) + challenges_response["challenges"][0]["challengeType"] = "CHALLENGE_TYPE_UNSPECIFIED" + with pytest.raises(exceptions.ReauthFailError) as excinfo: + reauth._run_next_challenge(challenges_response, MOCK_REQUEST, "token") + assert excinfo.match(r"Unsupported challenge type CHALLENGE_TYPE_UNSPECIFIED") + + +def test__run_next_challenge_not_locally_eligible(): + mock_challenge = MockChallenge("PASSWORD", False, "challenge_input") + with mock.patch( + "google.oauth2.challenges.AVAILABLE_CHALLENGES", {"PASSWORD": mock_challenge} + ): + with pytest.raises(exceptions.ReauthFailError) as excinfo: + reauth._run_next_challenge( + CHALLENGES_RESPONSE_TEMPLATE, MOCK_REQUEST, "token" + ) + assert excinfo.match(r"Challenge PASSWORD is not locally eligible") + + +def test__run_next_challenge_no_challenge_input(): + mock_challenge = MockChallenge("PASSWORD", True, None) + with mock.patch( + "google.oauth2.challenges.AVAILABLE_CHALLENGES", {"PASSWORD": mock_challenge} + ): + assert ( + reauth._run_next_challenge( + CHALLENGES_RESPONSE_TEMPLATE, MOCK_REQUEST, "token" + ) + is None + ) + + +def test__run_next_challenge_success(): + mock_challenge = MockChallenge("PASSWORD", True, {"credential": "password"}) + with mock.patch( + "google.oauth2.challenges.AVAILABLE_CHALLENGES", {"PASSWORD": mock_challenge} + ): + with mock.patch( + "google.oauth2.reauth._send_challenge_result" + ) as mock_send_challenge_result: + reauth._run_next_challenge( + CHALLENGES_RESPONSE_TEMPLATE, MOCK_REQUEST, "token" + ) + mock_send_challenge_result.assert_called_with( + MOCK_REQUEST, "123", 1, {"credential": "password"}, "token" + ) + + +def test__obtain_rapt_authenticated(): + with mock.patch( + "google.oauth2.reauth._get_challenges", + return_value=CHALLENGES_RESPONSE_AUTHENTICATED, + ): + assert reauth._obtain_rapt(MOCK_REQUEST, "token", None) == "new_rapt_token" + + +def test__obtain_rapt_authenticated_after_run_next_challenge(): + with mock.patch( + "google.oauth2.reauth._get_challenges", + return_value=CHALLENGES_RESPONSE_TEMPLATE, + ): + with mock.patch( + "google.oauth2.reauth._run_next_challenge", + side_effect=[ + CHALLENGES_RESPONSE_TEMPLATE, + CHALLENGES_RESPONSE_AUTHENTICATED, + ], + ): + with mock.patch("google.oauth2.reauth.is_interactive", return_value=True): + assert ( + reauth._obtain_rapt(MOCK_REQUEST, "token", None) == "new_rapt_token" + ) + + +def test__obtain_rapt_unsupported_status(): + challenges_response = copy.deepcopy(CHALLENGES_RESPONSE_TEMPLATE) + challenges_response["status"] = "STATUS_UNSPECIFIED" + with mock.patch( + "google.oauth2.reauth._get_challenges", return_value=challenges_response + ): + with pytest.raises(exceptions.ReauthFailError) as excinfo: + reauth._obtain_rapt(MOCK_REQUEST, "token", None) + assert excinfo.match(r"API error: STATUS_UNSPECIFIED") + + +def test__obtain_rapt_no_challenge_output(): + challenges_response = copy.deepcopy(CHALLENGES_RESPONSE_TEMPLATE) + with mock.patch( + "google.oauth2.reauth._get_challenges", return_value=challenges_response + ): + with mock.patch("google.oauth2.reauth.is_interactive", return_value=True): + with mock.patch( + "google.oauth2.reauth._run_next_challenge", return_value=None + ): + with pytest.raises(exceptions.ReauthFailError) as excinfo: + reauth._obtain_rapt(MOCK_REQUEST, "token", None) + assert excinfo.match(r"Failed to obtain rapt token") + + +def test__obtain_rapt_not_interactive(): + with mock.patch( + "google.oauth2.reauth._get_challenges", + return_value=CHALLENGES_RESPONSE_TEMPLATE, + ): + with mock.patch("google.oauth2.reauth.is_interactive", return_value=False): + with pytest.raises(exceptions.ReauthFailError) as excinfo: + reauth._obtain_rapt(MOCK_REQUEST, "token", None) + assert excinfo.match(r"not in an interactive session") + + +def test__obtain_rapt_not_authenticated(): + with mock.patch( + "google.oauth2.reauth._get_challenges", + return_value=CHALLENGES_RESPONSE_TEMPLATE, + ): + with mock.patch("google.oauth2.reauth.RUN_CHALLENGE_RETRY_LIMIT", 0): + with pytest.raises(exceptions.ReauthFailError) as excinfo: + reauth._obtain_rapt(MOCK_REQUEST, "token", None) + assert excinfo.match(r"Reauthentication failed") + + +def test_get_rapt_token(): + with mock.patch( + "google.oauth2._client.refresh_grant", return_value=("token", None, None, None) + ) as mock_refresh_grant: + with mock.patch( + "google.oauth2.reauth._obtain_rapt", return_value="new_rapt_token" + ) as mock_obtain_rapt: + assert ( + reauth.get_rapt_token( + MOCK_REQUEST, + "client_id", + "client_secret", + "refresh_token", + "token_uri", + ) + == "new_rapt_token" + ) + mock_refresh_grant.assert_called_with( + request=MOCK_REQUEST, + client_id="client_id", + client_secret="client_secret", + refresh_token="refresh_token", + token_uri="token_uri", + scopes=[reauth._REAUTH_SCOPE], + ) + mock_obtain_rapt.assert_called_with( + MOCK_REQUEST, "token", requested_scopes=None + ) + + +@mock.patch( + "google.auth.metrics.token_request_user", + return_value=TOKEN_REQUEST_METRICS_HEADER_VALUE, +) +def test_refresh_grant_failed(mock_metrics_header_value): + with mock.patch( + "google.oauth2._client._token_endpoint_request_no_throw" + ) as mock_token_request: + mock_token_request.return_value = (False, {"error": "Bad request"}, False) + with pytest.raises(exceptions.RefreshError) as excinfo: + reauth.refresh_grant( + MOCK_REQUEST, + "token_uri", + "refresh_token", + "client_id", + "client_secret", + scopes=["foo", "bar"], + rapt_token="rapt_token", + enable_reauth_refresh=True, + ) + assert excinfo.match(r"Bad request") + assert not excinfo.value.retryable + mock_token_request.assert_called_with( + MOCK_REQUEST, + "token_uri", + { + "grant_type": "refresh_token", + "client_id": "client_id", + "client_secret": "client_secret", + "refresh_token": "refresh_token", + "scope": "foo bar", + "rapt": "rapt_token", + }, + headers={"x-goog-api-client": TOKEN_REQUEST_METRICS_HEADER_VALUE}, + ) + + +def test_refresh_grant_failed_with_string_type_response(): + with mock.patch( + "google.oauth2._client._token_endpoint_request_no_throw" + ) as mock_token_request: + mock_token_request.return_value = (False, "string type error", False) + with pytest.raises(exceptions.RefreshError) as excinfo: + reauth.refresh_grant( + MOCK_REQUEST, + "token_uri", + "refresh_token", + "client_id", + "client_secret", + scopes=["foo", "bar"], + rapt_token="rapt_token", + enable_reauth_refresh=True, + ) + assert excinfo.match(r"string type error") + assert not excinfo.value.retryable + + +def test_refresh_grant_success(): + with mock.patch( + "google.oauth2._client._token_endpoint_request_no_throw" + ) as mock_token_request: + mock_token_request.side_effect = [ + (False, {"error": "invalid_grant", "error_subtype": "rapt_required"}, True), + (True, {"access_token": "access_token"}, None), + ] + with mock.patch( + "google.oauth2.reauth.get_rapt_token", return_value="new_rapt_token" + ): + assert reauth.refresh_grant( + MOCK_REQUEST, + "token_uri", + "refresh_token", + "client_id", + "client_secret", + enable_reauth_refresh=True, + ) == ( + "access_token", + "refresh_token", + None, + {"access_token": "access_token"}, + "new_rapt_token", + ) + + +def test_refresh_grant_reauth_refresh_disabled(): + with mock.patch( + "google.oauth2._client._token_endpoint_request_no_throw" + ) as mock_token_request: + mock_token_request.side_effect = [ + (False, {"error": "invalid_grant", "error_subtype": "rapt_required"}, True), + (True, {"access_token": "access_token"}, None), + ] + with pytest.raises(exceptions.RefreshError) as excinfo: + reauth.refresh_grant( + MOCK_REQUEST, "token_uri", "refresh_token", "client_id", "client_secret" + ) + assert excinfo.match(r"Reauthentication is needed") diff --git a/packages/google-auth/tests/oauth2/test_service_account.py b/packages/google-auth/tests/oauth2/test_service_account.py new file mode 100644 index 000000000000..d23746fdff67 --- /dev/null +++ b/packages/google-auth/tests/oauth2/test_service_account.py @@ -0,0 +1,1123 @@ +# Copyright 2016 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 datetime +import json +import os + +import mock +import pytest # type: ignore + +from google.auth import _helpers +from google.auth import credentials +from google.auth import crypt +from google.auth import environment_vars +from google.auth import exceptions +from google.auth import iam +from google.auth import jwt +from google.auth import transport +from google.auth.credentials import DEFAULT_UNIVERSE_DOMAIN +from google.oauth2 import service_account + + +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() + +with open(os.path.join(DATA_DIR, "public_cert.pem"), "rb") as fh: + PUBLIC_CERT_BYTES = fh.read() + +with open(os.path.join(DATA_DIR, "other_cert.pem"), "rb") as fh: + OTHER_CERT_BYTES = fh.read() + +SERVICE_ACCOUNT_JSON_FILE = os.path.join(DATA_DIR, "service_account.json") +SERVICE_ACCOUNT_NON_GDU_JSON_FILE = os.path.join( + DATA_DIR, "service_account_non_gdu.json" +) +FAKE_UNIVERSE_DOMAIN = "universe.foo" + +with open(SERVICE_ACCOUNT_JSON_FILE, "rb") as fh: + SERVICE_ACCOUNT_INFO = json.load(fh) + +with open(SERVICE_ACCOUNT_NON_GDU_JSON_FILE, "rb") as fh: + SERVICE_ACCOUNT_INFO_NON_GDU = json.load(fh) + +SIGNER = crypt.RSASigner.from_string(PRIVATE_KEY_BYTES, "1") + + +class TestCredentials(object): + SERVICE_ACCOUNT_EMAIL = "service-account@example.com" + TOKEN_URI = "https://example.com/oauth2/token" + NO_OP_TRUST_BOUNDARY = { + "locations": credentials.NO_OP_TRUST_BOUNDARY_LOCATIONS, + "encodedLocations": credentials.NO_OP_TRUST_BOUNDARY_ENCODED_LOCATIONS, + } + VALID_TRUST_BOUNDARY = { + "locations": ["us-central1", "us-east1"], + "encodedLocations": "0xVALIDHEXSA", + } + EXPECTED_TRUST_BOUNDARY_LOOKUP_URL_DEFAULT_UNIVERSE = ( + "https://iamcredentials.googleapis.com/v1/projects/-" + "/serviceAccounts/service-account@example.com/allowedLocations" + ) + + @classmethod + def make_credentials( + cls, + universe_domain=DEFAULT_UNIVERSE_DOMAIN, + trust_boundary=None, # Align with Credentials class default + ): + return service_account.Credentials( + SIGNER, + cls.SERVICE_ACCOUNT_EMAIL, + cls.TOKEN_URI, + universe_domain=universe_domain, + trust_boundary=trust_boundary, + ) + + def test_get_cred_info(self): + credentials = self.make_credentials() + assert not credentials.get_cred_info() + + credentials._cred_file_path = "/path/to/file" + assert credentials.get_cred_info() == { + "credential_source": "/path/to/file", + "credential_type": "service account credentials", + "principal": "service-account@example.com", + } + + def test__make_copy_get_cred_info(self): + credentials = self.make_credentials() + credentials._cred_file_path = "/path/to/file" + cred_copy = credentials._make_copy() + assert cred_copy._cred_file_path == "/path/to/file" + + def test_constructor_no_universe_domain(self): + credentials = service_account.Credentials( + SIGNER, self.SERVICE_ACCOUNT_EMAIL, self.TOKEN_URI, universe_domain=None + ) + assert credentials.universe_domain == DEFAULT_UNIVERSE_DOMAIN + + def test_from_service_account_info(self): + credentials = service_account.Credentials.from_service_account_info( + SERVICE_ACCOUNT_INFO + ) + + assert credentials._signer.key_id == SERVICE_ACCOUNT_INFO["private_key_id"] + assert credentials.service_account_email == SERVICE_ACCOUNT_INFO["client_email"] + assert credentials._token_uri == SERVICE_ACCOUNT_INFO["token_uri"] + assert credentials._universe_domain == DEFAULT_UNIVERSE_DOMAIN + assert not credentials._always_use_jwt_access + + def test_from_service_account_info_non_gdu(self): + credentials = service_account.Credentials.from_service_account_info( + SERVICE_ACCOUNT_INFO_NON_GDU + ) + + assert credentials.universe_domain == FAKE_UNIVERSE_DOMAIN + assert credentials._always_use_jwt_access + + def test_from_service_account_info_args(self): + info = SERVICE_ACCOUNT_INFO.copy() + scopes = ["email", "profile"] + subject = "subject" + additional_claims = {"meta": "data"} + + credentials = service_account.Credentials.from_service_account_info( + info, scopes=scopes, subject=subject, additional_claims=additional_claims + ) + + assert credentials.service_account_email == info["client_email"] + assert credentials.project_id == info["project_id"] + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._token_uri == info["token_uri"] + assert credentials._scopes == scopes + assert credentials._subject == subject + assert credentials._additional_claims == additional_claims + assert not credentials._always_use_jwt_access + + def test_from_service_account_file(self): + info = SERVICE_ACCOUNT_INFO.copy() + + credentials = service_account.Credentials.from_service_account_file( + SERVICE_ACCOUNT_JSON_FILE + ) + + assert credentials.service_account_email == info["client_email"] + assert credentials.project_id == info["project_id"] + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._token_uri == info["token_uri"] + + def test_from_service_account_file_non_gdu(self): + info = SERVICE_ACCOUNT_INFO_NON_GDU.copy() + + credentials = service_account.Credentials.from_service_account_file( + SERVICE_ACCOUNT_NON_GDU_JSON_FILE + ) + + assert credentials.service_account_email == info["client_email"] + assert credentials.project_id == info["project_id"] + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._token_uri == info["token_uri"] + assert credentials._universe_domain == FAKE_UNIVERSE_DOMAIN + assert credentials._always_use_jwt_access + + def test_from_service_account_file_args(self): + info = SERVICE_ACCOUNT_INFO.copy() + scopes = ["email", "profile"] + subject = "subject" + additional_claims = {"meta": "data"} + + credentials = service_account.Credentials.from_service_account_file( + SERVICE_ACCOUNT_JSON_FILE, + subject=subject, + scopes=scopes, + additional_claims=additional_claims, + ) + + assert credentials.service_account_email == info["client_email"] + assert credentials.project_id == info["project_id"] + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._token_uri == info["token_uri"] + assert credentials._scopes == scopes + assert credentials._subject == subject + assert credentials._additional_claims == additional_claims + + def test_default_state(self): + credentials = self.make_credentials() + assert not credentials.valid + # Expiration hasn't been set yet + assert not credentials.expired + # Scopes haven't been specified yet + assert credentials.requires_scopes + + def test_sign_bytes(self): + credentials = self.make_credentials() + to_sign = b"123" + signature = credentials.sign_bytes(to_sign) + assert crypt.verify_signature(to_sign, signature, PUBLIC_CERT_BYTES) + + def test_signer(self): + credentials = self.make_credentials() + assert isinstance(credentials.signer, crypt.Signer) + + def test_signer_email(self): + credentials = self.make_credentials() + assert credentials.signer_email == self.SERVICE_ACCOUNT_EMAIL + + def test_create_scoped(self): + credentials = self.make_credentials() + scopes = ["email", "profile"] + credentials = credentials.with_scopes(scopes) + assert credentials._scopes == scopes + + def test_with_claims(self): + credentials = self.make_credentials() + new_credentials = credentials.with_claims({"meep": "moop"}) + assert new_credentials._additional_claims == {"meep": "moop"} + + def test_with_quota_project(self): + credentials = self.make_credentials() + new_credentials = credentials.with_quota_project("new-project-456") + assert new_credentials.quota_project_id == "new-project-456" + hdrs = {} + new_credentials.apply(hdrs, token="tok") + assert "x-goog-user-project" in hdrs + + def test_with_token_uri(self): + credentials = self.make_credentials() + new_token_uri = "https://example2.com/oauth2/token" + assert credentials._token_uri == self.TOKEN_URI + creds_with_new_token_uri = credentials.with_token_uri(new_token_uri) + assert creds_with_new_token_uri._token_uri == new_token_uri + + def test_with_universe_domain(self): + credentials = self.make_credentials() + + new_credentials = credentials.with_universe_domain("dummy_universe.com") + assert new_credentials.universe_domain == "dummy_universe.com" + assert new_credentials._always_use_jwt_access + + new_credentials = credentials.with_universe_domain("googleapis.com") + assert new_credentials.universe_domain == "googleapis.com" + assert not new_credentials._always_use_jwt_access + + def test__with_always_use_jwt_access(self): + credentials = self.make_credentials() + assert not credentials._always_use_jwt_access + + new_credentials = credentials.with_always_use_jwt_access(True) + assert new_credentials._always_use_jwt_access + + def test__with_always_use_jwt_access_non_default_universe_domain(self): + credentials = self.make_credentials(universe_domain=FAKE_UNIVERSE_DOMAIN) + with pytest.raises(exceptions.InvalidValue) as excinfo: + credentials.with_always_use_jwt_access(False) + + assert excinfo.match( + "always_use_jwt_access should be True for non-default universe domain" + ) + + def test_with_trust_boundary(self): + credentials = self.make_credentials() + new_boundary = {"encodedLocations": "new_boundary"} + new_credentials = credentials.with_trust_boundary(new_boundary) + + assert new_credentials is not credentials + assert new_credentials._trust_boundary == new_boundary + assert new_credentials._signer == credentials._signer + assert ( + new_credentials.service_account_email == credentials.service_account_email + ) + + def test__make_authorization_grant_assertion(self): + credentials = self.make_credentials() + 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 + + def test__make_authorization_grant_assertion_scoped(self): + credentials = self.make_credentials() + scopes = ["email", "profile"] + credentials = credentials.with_scopes(scopes) + token = credentials._make_authorization_grant_assertion() + payload = jwt.decode(token, PUBLIC_CERT_BYTES) + assert payload["scope"] == "email profile" + + def test__make_authorization_grant_assertion_subject(self): + credentials = self.make_credentials() + subject = "user@example.com" + credentials = credentials.with_subject(subject) + token = credentials._make_authorization_grant_assertion() + payload = jwt.decode(token, PUBLIC_CERT_BYTES) + assert payload["sub"] == subject + + def test_apply_with_quota_project_id(self): + credentials = service_account.Credentials( + SIGNER, + self.SERVICE_ACCOUNT_EMAIL, + self.TOKEN_URI, + quota_project_id="quota-project-123", + ) + + headers = {} + credentials.apply(headers, token="token") + + assert headers["x-goog-user-project"] == "quota-project-123" + assert "token" in headers["authorization"] + + def test_apply_with_no_quota_project_id(self): + credentials = service_account.Credentials( + SIGNER, self.SERVICE_ACCOUNT_EMAIL, self.TOKEN_URI + ) + + headers = {} + credentials.apply(headers, token="token") + + assert "x-goog-user-project" not in headers + assert "token" in headers["authorization"] + + @mock.patch("google.auth.jwt.Credentials", instance=True, autospec=True) + def test__create_self_signed_jwt(self, jwt): + credentials = service_account.Credentials( + SIGNER, self.SERVICE_ACCOUNT_EMAIL, self.TOKEN_URI + ) + + audience = "https://pubsub.googleapis.com" + credentials._create_self_signed_jwt(audience) + jwt.from_signing_credentials.assert_called_once_with(credentials, audience) + + @mock.patch("google.auth.jwt.Credentials", instance=True, autospec=True) + def test__create_self_signed_jwt_with_user_scopes(self, jwt): + credentials = service_account.Credentials( + SIGNER, self.SERVICE_ACCOUNT_EMAIL, self.TOKEN_URI, scopes=["foo"] + ) + + audience = "https://pubsub.googleapis.com" + credentials._create_self_signed_jwt(audience) + + # JWT should not be created if there are user-defined scopes + jwt.from_signing_credentials.assert_not_called() + + @mock.patch("google.auth.jwt.Credentials", instance=True, autospec=True) + def test__create_self_signed_jwt_always_use_jwt_access_with_audience(self, jwt): + credentials = service_account.Credentials( + SIGNER, + self.SERVICE_ACCOUNT_EMAIL, + self.TOKEN_URI, + default_scopes=["bar", "foo"], + always_use_jwt_access=True, + ) + + audience = "https://pubsub.googleapis.com" + credentials._create_self_signed_jwt(audience) + jwt.from_signing_credentials.assert_called_once_with(credentials, audience) + + @mock.patch("google.auth.jwt.Credentials", instance=True, autospec=True) + def test__create_self_signed_jwt_always_use_jwt_access_with_audience_similar_jwt_is_reused( + self, jwt + ): + credentials = service_account.Credentials( + SIGNER, + self.SERVICE_ACCOUNT_EMAIL, + self.TOKEN_URI, + default_scopes=["bar", "foo"], + always_use_jwt_access=True, + ) + + audience = "https://pubsub.googleapis.com" + credentials._create_self_signed_jwt(audience) + credentials._jwt_credentials._audience = audience + credentials._create_self_signed_jwt(audience) + jwt.from_signing_credentials.assert_called_once_with(credentials, audience) + + @mock.patch("google.auth.jwt.Credentials", instance=True, autospec=True) + def test__create_self_signed_jwt_always_use_jwt_access_with_scopes(self, jwt): + credentials = service_account.Credentials( + SIGNER, + self.SERVICE_ACCOUNT_EMAIL, + self.TOKEN_URI, + scopes=["bar", "foo"], + always_use_jwt_access=True, + ) + + audience = "https://pubsub.googleapis.com" + credentials._create_self_signed_jwt(audience) + jwt.from_signing_credentials.assert_called_once_with( + credentials, None, additional_claims={"scope": "bar foo"} + ) + + @mock.patch("google.auth.jwt.Credentials", instance=True, autospec=True) + def test__create_self_signed_jwt_always_use_jwt_access_with_scopes_similar_jwt_is_reused( + self, jwt + ): + credentials = service_account.Credentials( + SIGNER, + self.SERVICE_ACCOUNT_EMAIL, + self.TOKEN_URI, + scopes=["bar", "foo"], + always_use_jwt_access=True, + ) + + audience = "https://pubsub.googleapis.com" + credentials._create_self_signed_jwt(audience) + credentials._jwt_credentials.additional_claims = {"scope": "bar foo"} + credentials._create_self_signed_jwt(audience) + jwt.from_signing_credentials.assert_called_once_with( + credentials, None, additional_claims={"scope": "bar foo"} + ) + + @mock.patch("google.auth.jwt.Credentials", instance=True, autospec=True) + def test__create_self_signed_jwt_always_use_jwt_access_with_default_scopes( + self, jwt + ): + credentials = service_account.Credentials( + SIGNER, + self.SERVICE_ACCOUNT_EMAIL, + self.TOKEN_URI, + default_scopes=["bar", "foo"], + always_use_jwt_access=True, + ) + + credentials._create_self_signed_jwt(None) + jwt.from_signing_credentials.assert_called_once_with( + credentials, None, additional_claims={"scope": "bar foo"} + ) + + @mock.patch("google.auth.jwt.Credentials", instance=True, autospec=True) + def test__create_self_signed_jwt_always_use_jwt_access_with_default_scopes_similar_jwt_is_reused( + self, jwt + ): + credentials = service_account.Credentials( + SIGNER, + self.SERVICE_ACCOUNT_EMAIL, + self.TOKEN_URI, + default_scopes=["bar", "foo"], + always_use_jwt_access=True, + ) + + credentials._create_self_signed_jwt(None) + credentials._jwt_credentials.additional_claims = {"scope": "bar foo"} + credentials._create_self_signed_jwt(None) + jwt.from_signing_credentials.assert_called_once_with( + credentials, None, additional_claims={"scope": "bar foo"} + ) + + @mock.patch("google.auth.jwt.Credentials", instance=True, autospec=True) + def test__create_self_signed_jwt_always_use_jwt_access(self, jwt): + credentials = service_account.Credentials( + SIGNER, + self.SERVICE_ACCOUNT_EMAIL, + self.TOKEN_URI, + always_use_jwt_access=True, + ) + + credentials._create_self_signed_jwt(None) + jwt.from_signing_credentials.assert_not_called() + + def test_token_usage_metrics_assertion(self): + credentials = service_account.Credentials( + SIGNER, + self.SERVICE_ACCOUNT_EMAIL, + self.TOKEN_URI, + always_use_jwt_access=False, + ) + credentials.token = "token" + credentials.expiry = None + + headers = {} + credentials.before_request(mock.Mock(), None, None, headers) + assert headers["authorization"] == "Bearer token" + assert headers["x-goog-api-client"] == "cred-type/sa" + + def test_token_usage_metrics_self_signed_jwt(self): + credentials = service_account.Credentials( + SIGNER, + self.SERVICE_ACCOUNT_EMAIL, + self.TOKEN_URI, + always_use_jwt_access=True, + ) + credentials._create_self_signed_jwt("foo.googleapis.com") + credentials.token = "token" + credentials.expiry = None + + headers = {} + credentials.before_request(mock.Mock(), None, None, headers) + assert headers["authorization"] == "Bearer token" + assert headers["x-goog-api-client"] == "cred-type/jwt" + + @mock.patch("google.oauth2._client.jwt_grant", autospec=True) + def test_refresh_success(self, jwt_grant): + credentials = self.make_credentials() + token = "token" + jwt_grant.return_value = ( + token, + _helpers.utcnow() + datetime.timedelta(seconds=500), + {}, + ) + request = mock.create_autospec(transport.Request, instance=True) + + # Refresh credentials + credentials.refresh(request) + + # Check jwt grant call. + assert jwt_grant.called + + called_request, token_uri, assertion = jwt_grant.call_args[0] + assert called_request == request + assert token_uri == credentials._token_uri + assert jwt.decode(assertion, PUBLIC_CERT_BYTES) + # No further assertion done on the token, as there are separate tests + # for checking the authorization grant assertion. + + # Check that the credentials have the token. + assert credentials.token == token + + # Check that the credentials are valid (have a token and are not expired). + assert credentials.valid + + # Trust boundary should be None since env var is not set and no initial + # boundary was provided. + assert credentials._trust_boundary is None + + @mock.patch("google.oauth2._client._lookup_trust_boundary") + @mock.patch("google.oauth2._client.jwt_grant", autospec=True) + def test_refresh_skips_trust_boundary_lookup_non_default_universe( + self, mock_jwt_grant, mock_lookup_trust_boundary + ): + # Create credentials with a non-default universe domain + credentials = self.make_credentials(universe_domain=FAKE_UNIVERSE_DOMAIN) + token = "token" + mock_jwt_grant.return_value = ( + token, + _helpers.utcnow() + datetime.timedelta(seconds=500), + {}, + ) + request = mock.create_autospec(transport.Request, instance=True) + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + ): + credentials.refresh(request) + + # Ensure jwt_grant was called (token refresh happened) + mock_jwt_grant.assert_called_once() + # Ensure trust boundary lookup was not called + mock_lookup_trust_boundary.assert_not_called() + # Verify that x-allowed-locations header is not set by apply() + headers_applied = {} + credentials.apply(headers_applied) + assert "x-allowed-locations" not in headers_applied + + @mock.patch("google.oauth2._client.jwt_grant", autospec=True) + def test_before_request_refreshes(self, jwt_grant): + credentials = self.make_credentials() + token = "token" + jwt_grant.return_value = ( + token, + _helpers.utcnow() + datetime.timedelta(seconds=500), + None, + ) + request = mock.create_autospec(transport.Request, instance=True) + + # Credentials should start as invalid + assert not credentials.valid + + # before_request should cause a refresh + credentials.before_request(request, "GET", "http://example.com?a=1#3", {}) + + # The refresh endpoint should've been called. + assert jwt_grant.called + + # Credentials should now be valid. + assert credentials.valid + + @mock.patch("google.auth.jwt.Credentials._make_jwt") + def test_refresh_with_jwt_credentials(self, make_jwt): + credentials = self.make_credentials() + credentials._create_self_signed_jwt("https://pubsub.googleapis.com") + + request = mock.create_autospec(transport.Request, instance=True) + + token = "token" + expiry = _helpers.utcnow() + datetime.timedelta(seconds=500) + make_jwt.return_value = (b"token", expiry) + + # Credentials should start as invalid + assert not credentials.valid + + # before_request should cause a refresh + credentials.before_request(request, "GET", "http://example.com?a=1#3", {}) + + # Credentials should now be valid. + assert credentials.valid + + # Assert make_jwt was called + assert make_jwt.call_count == 1 + + assert credentials.token == token + assert credentials.expiry == expiry + + def test_refresh_with_jwt_credentials_token_type_check(self): + credentials = self.make_credentials() + credentials._create_self_signed_jwt("https://pubsub.googleapis.com") + credentials.refresh(mock.Mock()) + + # Credentials token should be a JWT string. + assert isinstance(credentials.token, str) + payload = jwt.decode(credentials.token, verify=False) + assert payload["aud"] == "https://pubsub.googleapis.com" + + @mock.patch("google.oauth2._client.jwt_grant", autospec=True) + @mock.patch("google.auth.jwt.Credentials.refresh", autospec=True) + def test_refresh_jwt_not_used_for_domain_wide_delegation( + self, self_signed_jwt_refresh, jwt_grant + ): + # Create a domain wide delegation credentials by setting the subject. + credentials = service_account.Credentials( + SIGNER, + self.SERVICE_ACCOUNT_EMAIL, + self.TOKEN_URI, + always_use_jwt_access=True, + subject="subject", + ) + credentials._create_self_signed_jwt("https://pubsub.googleapis.com") + jwt_grant.return_value = ( + "token", + _helpers.utcnow() + datetime.timedelta(seconds=500), + {}, + ) + request = mock.create_autospec(transport.Request, instance=True) + + # Refresh credentials + credentials.refresh(request) + + # Make sure we are using jwt_grant and not self signed JWT refresh + # method to obtain the token. + assert jwt_grant.called + assert not self_signed_jwt_refresh.called + + def test_refresh_missing_jwt_credentials(self): + credentials = self.make_credentials() + credentials = credentials.with_scopes(["foo", "bar"]) + credentials = credentials.with_always_use_jwt_access(True) + assert not credentials._jwt_credentials + + credentials.refresh(mock.Mock()) + + # jwt credentials should have been automatically created with scopes + assert credentials._jwt_credentials is not None + + def test_refresh_non_gdu_domain_wide_delegation_not_supported(self): + credentials = self.make_credentials(universe_domain="foo") + credentials._subject = "bar@example.com" + credentials._create_self_signed_jwt("https://pubsub.googleapis.com") + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.refresh(None) + assert excinfo.match("domain wide delegation is not supported") + + @mock.patch("google.oauth2._client._lookup_trust_boundary") + @mock.patch("google.oauth2._client.jwt_grant", autospec=True) + def test_refresh_success_with_valid_trust_boundary( + self, mock_jwt_grant, mock_lookup_trust_boundary + ): + # Start with no boundary. + credentials = self.make_credentials(trust_boundary=None) + token = "token" + mock_jwt_grant.return_value = ( + token, + _helpers.utcnow() + datetime.timedelta(seconds=500), + {}, + ) + request = mock.create_autospec(transport.Request, instance=True) + + # Mock the trust boundary lookup to return a valid boundary. + mock_lookup_trust_boundary.return_value = self.VALID_TRUST_BOUNDARY + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + ): + credentials.refresh(request) + + assert credentials.valid + assert credentials.token == token + + # Verify trust boundary was set. + assert credentials._trust_boundary == self.VALID_TRUST_BOUNDARY + + # Verify the mock was called with the correct URL. + mock_lookup_trust_boundary.assert_called_once_with( + request, + self.EXPECTED_TRUST_BOUNDARY_LOOKUP_URL_DEFAULT_UNIVERSE, + headers={"authorization": "Bearer token"}, + ) + + # Verify x-allowed-locations header is set correctly by apply(). + headers_applied = {} + credentials.apply(headers_applied) + assert ( + headers_applied["x-allowed-locations"] + == self.VALID_TRUST_BOUNDARY["encodedLocations"] + ) + + @mock.patch("google.oauth2._client._lookup_trust_boundary") + @mock.patch("google.oauth2._client.jwt_grant", autospec=True) + def test_refresh_fetches_no_op_trust_boundary( + self, mock_jwt_grant, mock_lookup_trust_boundary + ): + # Start with no trust boundary + credentials = self.make_credentials(trust_boundary=None) + token = "token" + mock_jwt_grant.return_value = ( + token, + _helpers.utcnow() + datetime.timedelta(seconds=500), + {}, + ) + request = mock.create_autospec(transport.Request, instance=True) + + mock_lookup_trust_boundary.return_value = self.NO_OP_TRUST_BOUNDARY + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + ): + credentials.refresh(request) + + assert credentials.valid + assert credentials.token == token + assert credentials._trust_boundary == self.NO_OP_TRUST_BOUNDARY + mock_lookup_trust_boundary.assert_called_once_with( + request, + self.EXPECTED_TRUST_BOUNDARY_LOOKUP_URL_DEFAULT_UNIVERSE, + headers={"authorization": "Bearer token"}, + ) + headers_applied = {} + credentials.apply(headers_applied) + assert headers_applied["x-allowed-locations"] == "" + + @mock.patch("google.oauth2._client._lookup_trust_boundary") + @mock.patch("google.oauth2._client.jwt_grant", autospec=True) + def test_refresh_starts_with_no_op_trust_boundary_skips_lookup( + self, mock_jwt_grant, mock_lookup_trust_boundary + ): + credentials = self.make_credentials( + trust_boundary=self.NO_OP_TRUST_BOUNDARY + ) # Start with NO_OP + token = "token" + mock_jwt_grant.return_value = ( + token, + _helpers.utcnow() + datetime.timedelta(seconds=500), + {}, + ) + request = mock.create_autospec(transport.Request, instance=True) + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + ): + credentials.refresh(request) + + assert credentials.valid + assert credentials.token == token + # Verify trust boundary remained NO_OP + assert credentials._trust_boundary == self.NO_OP_TRUST_BOUNDARY + + # Lookup should be skipped + mock_lookup_trust_boundary.assert_not_called() + + # Verify that an empty header was added. + headers_applied = {} + credentials.apply(headers_applied) + assert headers_applied["x-allowed-locations"] == "" + + @mock.patch("google.oauth2._client._lookup_trust_boundary") + @mock.patch("google.oauth2._client.jwt_grant", autospec=True) + def test_refresh_trust_boundary_lookup_fails_no_cache( + self, mock_jwt_grant, mock_lookup_trust_boundary + ): + # Start with no trust boundary + credentials = self.make_credentials(trust_boundary=None) + mock_lookup_trust_boundary.side_effect = exceptions.RefreshError( + "Lookup failed" + ) + mock_jwt_grant.return_value = ( + "mock_access_token", + _helpers.utcnow() + datetime.timedelta(seconds=3600), + {}, + ) + request = mock.create_autospec(transport.Request, instance=True) + + # Mock the trust boundary lookup to raise an error + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + ), pytest.raises(exceptions.RefreshError, match="Lookup failed"): + credentials.refresh(request) + + assert credentials._trust_boundary is None + mock_lookup_trust_boundary.assert_called_once() + + @mock.patch("google.oauth2._client._lookup_trust_boundary") + @mock.patch("google.oauth2._client.jwt_grant", autospec=True) + def test_refresh_trust_boundary_lookup_fails_with_cached_data( + self, mock_jwt_grant, mock_lookup_trust_boundary + ): + # Initial setup: Credentials with no trust boundary. + credentials = self.make_credentials(trust_boundary=None) + token = "token" + mock_jwt_grant.return_value = ( + token, + _helpers.utcnow() + datetime.timedelta(seconds=500), + {}, + ) + request = mock.create_autospec(transport.Request, instance=True) + + # First refresh: Successfully fetch a valid trust boundary. + mock_lookup_trust_boundary.return_value = self.VALID_TRUST_BOUNDARY + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + ): + credentials.refresh(request) + + assert credentials.valid + assert credentials.token == token + assert credentials._trust_boundary == self.VALID_TRUST_BOUNDARY + mock_lookup_trust_boundary.assert_called_once_with( + request, + self.EXPECTED_TRUST_BOUNDARY_LOOKUP_URL_DEFAULT_UNIVERSE, + headers={"authorization": "Bearer token"}, + ) + + # Second refresh: Mock lookup to fail, but expect cached data to be preserved. + mock_lookup_trust_boundary.reset_mock() + mock_lookup_trust_boundary.side_effect = exceptions.RefreshError( + "Lookup failed" + ) + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + ): + credentials.refresh(request) # This should NOT raise an exception + + assert credentials.valid # Credentials should still be valid + assert ( + credentials._trust_boundary == self.VALID_TRUST_BOUNDARY + ) # Cached data should be preserved + mock_lookup_trust_boundary.assert_called_once_with( + request, + self.EXPECTED_TRUST_BOUNDARY_LOOKUP_URL_DEFAULT_UNIVERSE, + headers={ + "authorization": "Bearer token", + "x-allowed-locations": self.VALID_TRUST_BOUNDARY["encodedLocations"], + }, + ) # Lookup should have been attempted again + + def test_build_trust_boundary_lookup_url_no_email(self): + credentials = self.make_credentials() + credentials._service_account_email = None + + with pytest.raises(ValueError) as excinfo: + credentials._build_trust_boundary_lookup_url() + + assert "Service account email is required" in str(excinfo.value) + + +class TestIDTokenCredentials(object): + SERVICE_ACCOUNT_EMAIL = "service-account@example.com" + TOKEN_URI = "https://example.com/oauth2/token" + TARGET_AUDIENCE = "https://example.com" + + @classmethod + def make_credentials(cls, universe_domain=DEFAULT_UNIVERSE_DOMAIN): + return service_account.IDTokenCredentials( + SIGNER, + cls.SERVICE_ACCOUNT_EMAIL, + cls.TOKEN_URI, + cls.TARGET_AUDIENCE, + universe_domain=universe_domain, + ) + + def test_constructor_no_universe_domain(self): + credentials = service_account.IDTokenCredentials( + SIGNER, + self.SERVICE_ACCOUNT_EMAIL, + self.TOKEN_URI, + self.TARGET_AUDIENCE, + universe_domain=None, + ) + assert credentials._universe_domain == DEFAULT_UNIVERSE_DOMAIN + + def test_from_service_account_info(self): + credentials = service_account.IDTokenCredentials.from_service_account_info( + SERVICE_ACCOUNT_INFO, target_audience=self.TARGET_AUDIENCE + ) + + assert credentials._signer.key_id == SERVICE_ACCOUNT_INFO["private_key_id"] + 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_info_non_gdu(self): + credentials = service_account.IDTokenCredentials.from_service_account_info( + SERVICE_ACCOUNT_INFO_NON_GDU, target_audience=self.TARGET_AUDIENCE + ) + + assert ( + credentials._signer.key_id == SERVICE_ACCOUNT_INFO_NON_GDU["private_key_id"] + ) + assert ( + credentials.service_account_email + == SERVICE_ACCOUNT_INFO_NON_GDU["client_email"] + ) + assert credentials._token_uri == SERVICE_ACCOUNT_INFO_NON_GDU["token_uri"] + assert credentials._target_audience == self.TARGET_AUDIENCE + assert credentials._use_iam_endpoint + + def test_from_service_account_file(self): + info = SERVICE_ACCOUNT_INFO.copy() + + credentials = service_account.IDTokenCredentials.from_service_account_file( + SERVICE_ACCOUNT_JSON_FILE, target_audience=self.TARGET_AUDIENCE + ) + + assert credentials.service_account_email == info["client_email"] + 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_from_service_account_file_non_gdu(self): + info = SERVICE_ACCOUNT_INFO_NON_GDU.copy() + + credentials = service_account.IDTokenCredentials.from_service_account_file( + SERVICE_ACCOUNT_NON_GDU_JSON_FILE, target_audience=self.TARGET_AUDIENCE + ) + + assert credentials.service_account_email == info["client_email"] + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._token_uri == info["token_uri"] + assert credentials._target_audience == self.TARGET_AUDIENCE + assert credentials._use_iam_endpoint + + def test_default_state(self): + credentials = self.make_credentials() + assert not credentials.valid + # Expiration hasn't been set yet + assert not credentials.expired + + def test_sign_bytes(self): + credentials = self.make_credentials() + to_sign = b"123" + signature = credentials.sign_bytes(to_sign) + assert crypt.verify_signature(to_sign, signature, PUBLIC_CERT_BYTES) + + def test_signer(self): + credentials = self.make_credentials() + assert isinstance(credentials.signer, crypt.Signer) + + def test_signer_email(self): + credentials = self.make_credentials() + assert credentials.signer_email == self.SERVICE_ACCOUNT_EMAIL + + def test_with_target_audience(self): + credentials = self.make_credentials() + 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_use_iam_endpoint_non_default_universe_domain(self): + credentials = self.make_credentials(universe_domain=FAKE_UNIVERSE_DOMAIN) + with pytest.raises(exceptions.InvalidValue) as excinfo: + credentials._with_use_iam_endpoint(False) + + assert excinfo.match( + "use_iam_endpoint should be True for non-default universe domain" + ) + + def test_with_quota_project(self): + credentials = self.make_credentials() + new_credentials = credentials.with_quota_project("project-foo") + assert new_credentials._quota_project_id == "project-foo" + + def test_with_token_uri(self): + credentials = self.make_credentials() + new_token_uri = "https://example2.com/oauth2/token" + assert credentials._token_uri == self.TOKEN_URI + creds_with_new_token_uri = credentials.with_token_uri(new_token_uri) + assert creds_with_new_token_uri._token_uri == new_token_uri + + def test__make_authorization_grant_assertion(self): + credentials = self.make_credentials() + 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["target_audience"] == self.TARGET_AUDIENCE + + @mock.patch("google.oauth2._client.id_token_jwt_grant", autospec=True) + def test_refresh_success(self, id_token_jwt_grant): + credentials = self.make_credentials() + token = "token" + id_token_jwt_grant.return_value = ( + token, + _helpers.utcnow() + datetime.timedelta(seconds=500), + {}, + ) + request = mock.create_autospec(transport.Request, instance=True) + + # Refresh credentials + credentials.refresh(request) + + # Check jwt grant call. + assert id_token_jwt_grant.called + + called_request, token_uri, assertion = id_token_jwt_grant.call_args[0] + assert called_request == request + assert token_uri == credentials._token_uri + assert jwt.decode(assertion, PUBLIC_CERT_BYTES) + # No further assertion done on the token, as there are separate tests + # for checking the authorization grant assertion. + + # Check that the credentials have the token. + assert credentials.token == token + + # Check that the credentials are valid (have a token and are not + # 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, + iam_endpoint, + signer_email, + target_audience, + access_token, + universe_domain, + ) = call_iam_generate_id_token_endpoint.call_args[0] + assert req == request + assert iam_endpoint == iam._IAM_IDTOKEN_ENDPOINT + 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.call_iam_generate_id_token_endpoint", autospec=True + ) + def test_refresh_iam_flow_non_gdu(self, call_iam_generate_id_token_endpoint): + credentials = self.make_credentials(universe_domain="fake-universe") + 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, + iam_endpoint, + signer_email, + target_audience, + access_token, + universe_domain, + ) = call_iam_generate_id_token_endpoint.call_args[0] + assert req == request + assert ( + iam_endpoint + == "https://iamcredentials.fake-universe/v1/projects/-/serviceAccounts/{}:generateIdToken" + ) + 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() + token = "token" + id_token_jwt_grant.return_value = ( + token, + _helpers.utcnow() + datetime.timedelta(seconds=500), + None, + ) + request = mock.create_autospec(transport.Request, instance=True) + + # Credentials should start as invalid + assert not credentials.valid + + # before_request should cause a refresh + credentials.before_request(request, "GET", "http://example.com?a=1#3", {}) + + # The refresh endpoint should've been called. + assert id_token_jwt_grant.called + + # Credentials should now be valid. + assert credentials.valid diff --git a/packages/google-auth/tests/oauth2/test_sts.py b/packages/google-auth/tests/oauth2/test_sts.py new file mode 100644 index 000000000000..71856a6232bc --- /dev/null +++ b/packages/google-auth/tests/oauth2/test_sts.py @@ -0,0 +1,551 @@ +# 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 http.client as http_client +import json +import urllib + +import mock +import pytest # type: ignore + +from google.auth import exceptions +from google.auth import transport +from google.oauth2 import sts +from google.oauth2 import utils + +CLIENT_ID = "username" +CLIENT_SECRET = "password" +# Base64 encoding of "username:password" +BASIC_AUTH_ENCODING = "dXNlcm5hbWU6cGFzc3dvcmQ=" + + +class TestStsClient(object): + GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange" + RESOURCE = "https://api.example.com/" + AUDIENCE = "urn:example:cooperation-context" + SCOPES = ["scope1", "scope2"] + REQUESTED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token" + SUBJECT_TOKEN = "HEADER.SUBJECT_TOKEN_PAYLOAD.SIGNATURE" + SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt" + ACTOR_TOKEN = "HEADER.ACTOR_TOKEN_PAYLOAD.SIGNATURE" + ACTOR_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt" + TOKEN_EXCHANGE_ENDPOINT = "https://example.com/token.oauth2" + REVOKE_URL = "https://example.com/revoke.oauth2" + TOKEN_TO_REVOKE = "TOKEN_TO_REVOKE" + TOKEN_TYPE_HINT = "refresh_token" + ADDON_HEADERS = {"x-client-version": "0.1.2"} + ADDON_OPTIONS = {"additional": {"non-standard": ["options"], "other": "some-value"}} + SUCCESS_RESPONSE = { + "access_token": "ACCESS_TOKEN", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "scope1 scope2", + } + SUCCESS_RESPONSE_WITH_REFRESH = { + "access_token": "abc", + "refresh_token": "xyz", + "expires_in": 3600, + } + ERROR_RESPONSE = { + "error": "invalid_request", + "error_description": "Invalid subject token", + "error_uri": "https://tools.ietf.org/html/rfc6749", + } + CLIENT_AUTH_BASIC = utils.ClientAuthentication( + utils.ClientAuthType.basic, CLIENT_ID, CLIENT_SECRET + ) + CLIENT_AUTH_REQUEST_BODY = utils.ClientAuthentication( + utils.ClientAuthType.request_body, CLIENT_ID, CLIENT_SECRET + ) + + @classmethod + def make_client(cls, client_auth=None): + return sts.Client(cls.TOKEN_EXCHANGE_ENDPOINT, client_auth) + + @classmethod + def make_mock_request(cls, data, status=http_client.OK, use_json=True): + response = mock.create_autospec(transport.Response, instance=True) + response.status = status + if use_json: + response.data = json.dumps(data).encode("utf-8") + else: + response.data = data.encode("utf-8") + + request = mock.create_autospec(transport.Request) + request.return_value = response + + return request + + @classmethod + def assert_request_kwargs(cls, request_kwargs, headers, request_data, url=None): + """Asserts the request was called with the expected parameters.""" + url = url or cls.TOKEN_EXCHANGE_ENDPOINT + assert request_kwargs["url"] == url + assert request_kwargs["method"] == "POST" + assert request_kwargs["headers"] == headers + assert request_kwargs["body"] is not None + body_tuples = urllib.parse.parse_qsl(request_kwargs["body"]) + for k, v in body_tuples: + assert v.decode("utf-8") == request_data[k.decode("utf-8")] + assert len(body_tuples) == len(request_data.keys()) + + def test_exchange_token_full_success_without_auth(self): + """Test token exchange success without client authentication using full + parameters. + """ + client = self.make_client() + headers = self.ADDON_HEADERS.copy() + headers["Content-Type"] = "application/x-www-form-urlencoded" + request_data = { + "grant_type": self.GRANT_TYPE, + "resource": self.RESOURCE, + "audience": self.AUDIENCE, + "scope": " ".join(self.SCOPES), + "requested_token_type": self.REQUESTED_TOKEN_TYPE, + "subject_token": self.SUBJECT_TOKEN, + "subject_token_type": self.SUBJECT_TOKEN_TYPE, + "actor_token": self.ACTOR_TOKEN, + "actor_token_type": self.ACTOR_TOKEN_TYPE, + "options": urllib.parse.quote(json.dumps(self.ADDON_OPTIONS)), + } + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + + response = client.exchange_token( + request, + self.GRANT_TYPE, + self.SUBJECT_TOKEN, + self.SUBJECT_TOKEN_TYPE, + self.RESOURCE, + self.AUDIENCE, + self.SCOPES, + self.REQUESTED_TOKEN_TYPE, + self.ACTOR_TOKEN, + self.ACTOR_TOKEN_TYPE, + self.ADDON_OPTIONS, + self.ADDON_HEADERS, + ) + + self.assert_request_kwargs(request.call_args[1], headers, request_data) + assert response == self.SUCCESS_RESPONSE + + def test_exchange_token_partial_success_without_auth(self): + """Test token exchange success without client authentication using + partial (required only) parameters. + """ + client = self.make_client() + headers = {"Content-Type": "application/x-www-form-urlencoded"} + request_data = { + "grant_type": self.GRANT_TYPE, + "audience": self.AUDIENCE, + "requested_token_type": self.REQUESTED_TOKEN_TYPE, + "subject_token": self.SUBJECT_TOKEN, + "subject_token_type": self.SUBJECT_TOKEN_TYPE, + } + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + + response = client.exchange_token( + request, + grant_type=self.GRANT_TYPE, + subject_token=self.SUBJECT_TOKEN, + subject_token_type=self.SUBJECT_TOKEN_TYPE, + audience=self.AUDIENCE, + requested_token_type=self.REQUESTED_TOKEN_TYPE, + ) + + self.assert_request_kwargs(request.call_args[1], headers, request_data) + assert response == self.SUCCESS_RESPONSE + + def test_exchange_token_non200_without_auth(self): + """Test token exchange without client auth responding with non-200 status.""" + client = self.make_client() + request = self.make_mock_request( + status=http_client.BAD_REQUEST, data=self.ERROR_RESPONSE + ) + + with pytest.raises(exceptions.OAuthError) as excinfo: + client.exchange_token( + request, + self.GRANT_TYPE, + self.SUBJECT_TOKEN, + self.SUBJECT_TOKEN_TYPE, + self.RESOURCE, + self.AUDIENCE, + self.SCOPES, + self.REQUESTED_TOKEN_TYPE, + self.ACTOR_TOKEN, + self.ACTOR_TOKEN_TYPE, + self.ADDON_OPTIONS, + self.ADDON_HEADERS, + ) + + assert excinfo.match( + r"Error code invalid_request: Invalid subject token - https://tools.ietf.org/html/rfc6749" + ) + + def test_exchange_token_full_success_with_basic_auth(self): + """Test token exchange success with basic client authentication using full + parameters. + """ + client = self.make_client(self.CLIENT_AUTH_BASIC) + headers = self.ADDON_HEADERS.copy() + headers["Content-Type"] = "application/x-www-form-urlencoded" + headers["Authorization"] = "Basic {}".format(BASIC_AUTH_ENCODING) + request_data = { + "grant_type": self.GRANT_TYPE, + "resource": self.RESOURCE, + "audience": self.AUDIENCE, + "scope": " ".join(self.SCOPES), + "requested_token_type": self.REQUESTED_TOKEN_TYPE, + "subject_token": self.SUBJECT_TOKEN, + "subject_token_type": self.SUBJECT_TOKEN_TYPE, + "actor_token": self.ACTOR_TOKEN, + "actor_token_type": self.ACTOR_TOKEN_TYPE, + "options": urllib.parse.quote(json.dumps(self.ADDON_OPTIONS)), + } + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + + response = client.exchange_token( + request, + self.GRANT_TYPE, + self.SUBJECT_TOKEN, + self.SUBJECT_TOKEN_TYPE, + self.RESOURCE, + self.AUDIENCE, + self.SCOPES, + self.REQUESTED_TOKEN_TYPE, + self.ACTOR_TOKEN, + self.ACTOR_TOKEN_TYPE, + self.ADDON_OPTIONS, + self.ADDON_HEADERS, + ) + + self.assert_request_kwargs(request.call_args[1], headers, request_data) + assert response == self.SUCCESS_RESPONSE + + def test_exchange_token_partial_success_with_basic_auth(self): + """Test token exchange success with basic client authentication using + partial (required only) parameters. + """ + client = self.make_client(self.CLIENT_AUTH_BASIC) + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Basic {}".format(BASIC_AUTH_ENCODING), + } + request_data = { + "grant_type": self.GRANT_TYPE, + "audience": self.AUDIENCE, + "requested_token_type": self.REQUESTED_TOKEN_TYPE, + "subject_token": self.SUBJECT_TOKEN, + "subject_token_type": self.SUBJECT_TOKEN_TYPE, + } + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + + response = client.exchange_token( + request, + grant_type=self.GRANT_TYPE, + subject_token=self.SUBJECT_TOKEN, + subject_token_type=self.SUBJECT_TOKEN_TYPE, + audience=self.AUDIENCE, + requested_token_type=self.REQUESTED_TOKEN_TYPE, + ) + + self.assert_request_kwargs(request.call_args[1], headers, request_data) + assert response == self.SUCCESS_RESPONSE + + def test_exchange_token_non200_with_basic_auth(self): + """Test token exchange with basic client auth responding with non-200 + status. + """ + client = self.make_client(self.CLIENT_AUTH_BASIC) + request = self.make_mock_request( + status=http_client.BAD_REQUEST, data=self.ERROR_RESPONSE + ) + + with pytest.raises(exceptions.OAuthError) as excinfo: + client.exchange_token( + request, + self.GRANT_TYPE, + self.SUBJECT_TOKEN, + self.SUBJECT_TOKEN_TYPE, + self.RESOURCE, + self.AUDIENCE, + self.SCOPES, + self.REQUESTED_TOKEN_TYPE, + self.ACTOR_TOKEN, + self.ACTOR_TOKEN_TYPE, + self.ADDON_OPTIONS, + self.ADDON_HEADERS, + ) + + assert excinfo.match( + r"Error code invalid_request: Invalid subject token - https://tools.ietf.org/html/rfc6749" + ) + + def test_exchange_token_full_success_with_reqbody_auth(self): + """Test token exchange success with request body client authenticaiton + using full parameters. + """ + client = self.make_client(self.CLIENT_AUTH_REQUEST_BODY) + headers = self.ADDON_HEADERS.copy() + headers["Content-Type"] = "application/x-www-form-urlencoded" + request_data = { + "grant_type": self.GRANT_TYPE, + "resource": self.RESOURCE, + "audience": self.AUDIENCE, + "scope": " ".join(self.SCOPES), + "requested_token_type": self.REQUESTED_TOKEN_TYPE, + "subject_token": self.SUBJECT_TOKEN, + "subject_token_type": self.SUBJECT_TOKEN_TYPE, + "actor_token": self.ACTOR_TOKEN, + "actor_token_type": self.ACTOR_TOKEN_TYPE, + "options": urllib.parse.quote(json.dumps(self.ADDON_OPTIONS)), + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + } + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + + response = client.exchange_token( + request, + self.GRANT_TYPE, + self.SUBJECT_TOKEN, + self.SUBJECT_TOKEN_TYPE, + self.RESOURCE, + self.AUDIENCE, + self.SCOPES, + self.REQUESTED_TOKEN_TYPE, + self.ACTOR_TOKEN, + self.ACTOR_TOKEN_TYPE, + self.ADDON_OPTIONS, + self.ADDON_HEADERS, + ) + + self.assert_request_kwargs(request.call_args[1], headers, request_data) + assert response == self.SUCCESS_RESPONSE + + def test_exchange_token_partial_success_with_reqbody_auth(self): + """Test token exchange success with request body client authentication + using partial (required only) parameters. + """ + client = self.make_client(self.CLIENT_AUTH_REQUEST_BODY) + headers = {"Content-Type": "application/x-www-form-urlencoded"} + request_data = { + "grant_type": self.GRANT_TYPE, + "audience": self.AUDIENCE, + "requested_token_type": self.REQUESTED_TOKEN_TYPE, + "subject_token": self.SUBJECT_TOKEN, + "subject_token_type": self.SUBJECT_TOKEN_TYPE, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + } + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + + response = client.exchange_token( + request, + grant_type=self.GRANT_TYPE, + subject_token=self.SUBJECT_TOKEN, + subject_token_type=self.SUBJECT_TOKEN_TYPE, + audience=self.AUDIENCE, + requested_token_type=self.REQUESTED_TOKEN_TYPE, + ) + + self.assert_request_kwargs(request.call_args[1], headers, request_data) + assert response == self.SUCCESS_RESPONSE + + def test_exchange_token_non200_with_reqbody_auth(self): + """Test token exchange with POST request body client auth responding + with non-200 status. + """ + client = self.make_client(self.CLIENT_AUTH_REQUEST_BODY) + request = self.make_mock_request( + status=http_client.BAD_REQUEST, data=self.ERROR_RESPONSE + ) + + with pytest.raises(exceptions.OAuthError) as excinfo: + client.exchange_token( + request, + self.GRANT_TYPE, + self.SUBJECT_TOKEN, + self.SUBJECT_TOKEN_TYPE, + self.RESOURCE, + self.AUDIENCE, + self.SCOPES, + self.REQUESTED_TOKEN_TYPE, + self.ACTOR_TOKEN, + self.ACTOR_TOKEN_TYPE, + self.ADDON_OPTIONS, + self.ADDON_HEADERS, + ) + + assert excinfo.match( + r"Error code invalid_request: Invalid subject token - https://tools.ietf.org/html/rfc6749" + ) + + def test_refresh_token_success(self): + """Test refresh token with successful response.""" + client = self.make_client(self.CLIENT_AUTH_BASIC) + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + + response = client.refresh_token(request, "refreshtoken") + + headers = { + "Authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=", + "Content-Type": "application/x-www-form-urlencoded", + } + request_data = {"grant_type": "refresh_token", "refresh_token": "refreshtoken"} + self.assert_request_kwargs(request.call_args[1], headers, request_data) + assert response == self.SUCCESS_RESPONSE + + def test_refresh_token_success_with_refresh(self): + """Test refresh token with successful response.""" + client = self.make_client(self.CLIENT_AUTH_BASIC) + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE_WITH_REFRESH + ) + + response = client.refresh_token(request, "refreshtoken") + + headers = { + "Authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=", + "Content-Type": "application/x-www-form-urlencoded", + } + request_data = {"grant_type": "refresh_token", "refresh_token": "refreshtoken"} + self.assert_request_kwargs(request.call_args[1], headers, request_data) + assert response == self.SUCCESS_RESPONSE_WITH_REFRESH + + def test_refresh_token_failure(self): + """Test refresh token with failure response.""" + client = self.make_client(self.CLIENT_AUTH_BASIC) + request = self.make_mock_request( + status=http_client.BAD_REQUEST, data=self.ERROR_RESPONSE + ) + + with pytest.raises(exceptions.OAuthError) as excinfo: + client.refresh_token(request, "refreshtoken") + + assert excinfo.match( + r"Error code invalid_request: Invalid subject token - https://tools.ietf.org/html/rfc6749" + ) + + def test_revoke_token_success(self): + """Test revoke token with successful response.""" + client = self.make_client(self.CLIENT_AUTH_BASIC) + request = self.make_mock_request(data="", status=http_client.OK, use_json=False) + + response = client.revoke_token( + request, self.TOKEN_TO_REVOKE, self.TOKEN_TYPE_HINT, self.REVOKE_URL + ) + + headers = { + "Authorization": "Basic {}".format(BASIC_AUTH_ENCODING), + "Content-Type": "application/x-www-form-urlencoded", + } + request_data = { + "token": self.TOKEN_TO_REVOKE, + "token_type_hint": self.TOKEN_TYPE_HINT, + } + self.assert_request_kwargs( + request.call_args[1], headers, request_data, url=self.REVOKE_URL + ) + assert response == {} + + def test_revoke_token_success_no_hint(self): + """Test revoke token with successful response.""" + client = self.make_client(self.CLIENT_AUTH_BASIC) + request = self.make_mock_request(data="", status=http_client.OK, use_json=False) + + response = client.revoke_token( + request, self.TOKEN_TO_REVOKE, None, self.REVOKE_URL + ) + + headers = { + "Authorization": "Basic {}".format(BASIC_AUTH_ENCODING), + "Content-Type": "application/x-www-form-urlencoded", + } + request_data = {"token": self.TOKEN_TO_REVOKE} + self.assert_request_kwargs( + request.call_args[1], headers, request_data, url=self.REVOKE_URL + ) + assert response == {} + + def test_revoke_token_failure(self): + """Test revoke token with failure response.""" + client = self.make_client(self.CLIENT_AUTH_BASIC) + request = self.make_mock_request( + status=http_client.BAD_REQUEST, data=self.ERROR_RESPONSE + ) + + with pytest.raises(exceptions.OAuthError) as excinfo: + client.revoke_token( + request, self.TOKEN_TO_REVOKE, self.TOKEN_TYPE_HINT, self.REVOKE_URL + ) + + assert excinfo.match( + r"Error code invalid_request: Invalid subject token - https://tools.ietf.org/html/rfc6749" + ) + + def test__make_request_success(self): + """Test base method with successful response.""" + client = self.make_client(self.CLIENT_AUTH_BASIC) + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + + response = client._make_request(request, {"a": "b"}, {"c": "d"}) + + headers = { + "Authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=", + "Content-Type": "application/x-www-form-urlencoded", + "a": "b", + } + request_data = {"c": "d"} + self.assert_request_kwargs(request.call_args[1], headers, request_data) + assert response == self.SUCCESS_RESPONSE + + def test_make_request_failure(self): + """Test refresh token with failure response.""" + client = self.make_client(self.CLIENT_AUTH_BASIC) + request = self.make_mock_request( + status=http_client.BAD_REQUEST, data=self.ERROR_RESPONSE + ) + + with pytest.raises(exceptions.OAuthError) as excinfo: + client._make_request(request, {"a": "b"}, {"c": "d"}) + + assert excinfo.match( + r"Error code invalid_request: Invalid subject token - https://tools.ietf.org/html/rfc6749" + ) + + def test__make_request_empty_response(self): + """Test _make_request with a successful but empty response body.""" + client = self.make_client() + request = self.make_mock_request(data="", status=http_client.OK, use_json=False) + + response = client._make_request(request, {}, {}) + + assert response == {} diff --git a/packages/google-auth/tests/oauth2/test_utils.py b/packages/google-auth/tests/oauth2/test_utils.py new file mode 100644 index 000000000000..543a693a98bf --- /dev/null +++ b/packages/google-auth/tests/oauth2/test_utils.py @@ -0,0 +1,264 @@ +# 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 json + +import pytest # type: ignore + +from google.auth import exceptions +from google.oauth2 import utils + + +CLIENT_ID = "username" +CLIENT_SECRET = "password" +# Base64 encoding of "username:password" +BASIC_AUTH_ENCODING = "dXNlcm5hbWU6cGFzc3dvcmQ=" +# Base64 encoding of "username:" +BASIC_AUTH_ENCODING_SECRETLESS = "dXNlcm5hbWU6" + + +class AuthHandler(utils.OAuthClientAuthHandler): + def __init__(self, client_auth=None): + super(AuthHandler, self).__init__(client_auth) + + def apply_client_authentication_options( + self, headers, request_body=None, bearer_token=None + ): + return super(AuthHandler, self).apply_client_authentication_options( + headers, request_body, bearer_token + ) + + +class TestClientAuthentication(object): + @classmethod + def make_client_auth(cls, client_secret=None): + return utils.ClientAuthentication( + utils.ClientAuthType.basic, CLIENT_ID, client_secret + ) + + def test_initialization_with_client_secret(self): + client_auth = self.make_client_auth(CLIENT_SECRET) + + assert client_auth.client_auth_type == utils.ClientAuthType.basic + assert client_auth.client_id == CLIENT_ID + assert client_auth.client_secret == CLIENT_SECRET + + def test_initialization_no_client_secret(self): + client_auth = self.make_client_auth() + + assert client_auth.client_auth_type == utils.ClientAuthType.basic + assert client_auth.client_id == CLIENT_ID + assert client_auth.client_secret is None + + +class TestOAuthClientAuthHandler(object): + CLIENT_AUTH_BASIC = utils.ClientAuthentication( + utils.ClientAuthType.basic, CLIENT_ID, CLIENT_SECRET + ) + CLIENT_AUTH_BASIC_SECRETLESS = utils.ClientAuthentication( + utils.ClientAuthType.basic, CLIENT_ID + ) + CLIENT_AUTH_REQUEST_BODY = utils.ClientAuthentication( + utils.ClientAuthType.request_body, CLIENT_ID, CLIENT_SECRET + ) + CLIENT_AUTH_REQUEST_BODY_SECRETLESS = utils.ClientAuthentication( + utils.ClientAuthType.request_body, CLIENT_ID + ) + + @classmethod + def make_oauth_client_auth_handler(cls, client_auth=None): + return AuthHandler(client_auth) + + def test_apply_client_authentication_options_none(self): + headers = {"Content-Type": "application/json"} + request_body = {"foo": "bar"} + auth_handler = self.make_oauth_client_auth_handler() + + auth_handler.apply_client_authentication_options(headers, request_body) + + assert headers == {"Content-Type": "application/json"} + assert request_body == {"foo": "bar"} + + def test_apply_client_authentication_options_basic(self): + headers = {"Content-Type": "application/json"} + request_body = {"foo": "bar"} + auth_handler = self.make_oauth_client_auth_handler(self.CLIENT_AUTH_BASIC) + + auth_handler.apply_client_authentication_options(headers, request_body) + + assert headers == { + "Content-Type": "application/json", + "Authorization": "Basic {}".format(BASIC_AUTH_ENCODING), + } + assert request_body == {"foo": "bar"} + + def test_apply_client_authentication_options_basic_nosecret(self): + headers = {"Content-Type": "application/json"} + request_body = {"foo": "bar"} + auth_handler = self.make_oauth_client_auth_handler( + self.CLIENT_AUTH_BASIC_SECRETLESS + ) + + auth_handler.apply_client_authentication_options(headers, request_body) + + assert headers == { + "Content-Type": "application/json", + "Authorization": "Basic {}".format(BASIC_AUTH_ENCODING_SECRETLESS), + } + assert request_body == {"foo": "bar"} + + def test_apply_client_authentication_options_request_body(self): + headers = {"Content-Type": "application/json"} + request_body = {"foo": "bar"} + auth_handler = self.make_oauth_client_auth_handler( + self.CLIENT_AUTH_REQUEST_BODY + ) + + auth_handler.apply_client_authentication_options(headers, request_body) + + assert headers == {"Content-Type": "application/json"} + assert request_body == { + "foo": "bar", + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + } + + def test_apply_client_authentication_options_request_body_nosecret(self): + headers = {"Content-Type": "application/json"} + request_body = {"foo": "bar"} + auth_handler = self.make_oauth_client_auth_handler( + self.CLIENT_AUTH_REQUEST_BODY_SECRETLESS + ) + + auth_handler.apply_client_authentication_options(headers, request_body) + + assert headers == {"Content-Type": "application/json"} + assert request_body == { + "foo": "bar", + "client_id": CLIENT_ID, + "client_secret": "", + } + + def test_apply_client_authentication_options_request_body_no_body(self): + headers = {"Content-Type": "application/json"} + auth_handler = self.make_oauth_client_auth_handler( + self.CLIENT_AUTH_REQUEST_BODY + ) + + with pytest.raises(exceptions.OAuthError) as excinfo: + auth_handler.apply_client_authentication_options(headers) + + assert excinfo.match(r"HTTP request does not support request-body") + + def test_apply_client_authentication_options_bearer_token(self): + bearer_token = "ACCESS_TOKEN" + headers = {"Content-Type": "application/json"} + request_body = {"foo": "bar"} + auth_handler = self.make_oauth_client_auth_handler() + + auth_handler.apply_client_authentication_options( + headers, request_body, bearer_token + ) + + assert headers == { + "Content-Type": "application/json", + "Authorization": "Bearer {}".format(bearer_token), + } + assert request_body == {"foo": "bar"} + + def test_apply_client_authentication_options_bearer_and_basic(self): + bearer_token = "ACCESS_TOKEN" + headers = {"Content-Type": "application/json"} + request_body = {"foo": "bar"} + auth_handler = self.make_oauth_client_auth_handler(self.CLIENT_AUTH_BASIC) + + auth_handler.apply_client_authentication_options( + headers, request_body, bearer_token + ) + + # Bearer token should have higher priority. + assert headers == { + "Content-Type": "application/json", + "Authorization": "Bearer {}".format(bearer_token), + } + assert request_body == {"foo": "bar"} + + def test_apply_client_authentication_options_bearer_and_request_body(self): + bearer_token = "ACCESS_TOKEN" + headers = {"Content-Type": "application/json"} + request_body = {"foo": "bar"} + auth_handler = self.make_oauth_client_auth_handler( + self.CLIENT_AUTH_REQUEST_BODY + ) + + auth_handler.apply_client_authentication_options( + headers, request_body, bearer_token + ) + + # Bearer token should have higher priority. + assert headers == { + "Content-Type": "application/json", + "Authorization": "Bearer {}".format(bearer_token), + } + assert request_body == {"foo": "bar"} + + +def test__handle_error_response_code_only(): + error_resp = {"error": "unsupported_grant_type"} + response_data = json.dumps(error_resp) + + with pytest.raises(exceptions.OAuthError) as excinfo: + utils.handle_error_response(response_data) + + assert excinfo.match(r"Error code unsupported_grant_type") + + +def test__handle_error_response_code_description(): + error_resp = { + "error": "unsupported_grant_type", + "error_description": "The provided grant_type is unsupported", + } + response_data = json.dumps(error_resp) + + with pytest.raises(exceptions.OAuthError) as excinfo: + utils.handle_error_response(response_data) + + assert excinfo.match( + r"Error code unsupported_grant_type: The provided grant_type is unsupported" + ) + + +def test__handle_error_response_code_description_uri(): + error_resp = { + "error": "unsupported_grant_type", + "error_description": "The provided grant_type is unsupported", + "error_uri": "https://tools.ietf.org/html/rfc6749", + } + response_data = json.dumps(error_resp) + + with pytest.raises(exceptions.OAuthError) as excinfo: + utils.handle_error_response(response_data) + + assert excinfo.match( + r"Error code unsupported_grant_type: The provided grant_type is unsupported - https://tools.ietf.org/html/rfc6749" + ) + + +def test__handle_error_response_non_json(): + response_data = "Oops, something wrong happened" + + with pytest.raises(exceptions.OAuthError) as excinfo: + utils.handle_error_response(response_data) + + assert excinfo.match(r"Oops, something wrong happened") diff --git a/packages/google-auth/tests/oauth2/test_webauthn_handler.py b/packages/google-auth/tests/oauth2/test_webauthn_handler.py new file mode 100644 index 000000000000..9fba266da9fc --- /dev/null +++ b/packages/google-auth/tests/oauth2/test_webauthn_handler.py @@ -0,0 +1,148 @@ +import json +import struct + +import mock +import pytest # type: ignore + +from google.auth import exceptions +from google.oauth2 import webauthn_handler +from google.oauth2 import webauthn_types + + +@pytest.fixture +def os_get_stub(): + with mock.patch.object( + webauthn_handler.os.environ, + "get", + return_value="gcloud_webauthn_plugin", + name="fake os.environ.get", + ) as mock_os_environ_get: + yield mock_os_environ_get + + +@pytest.fixture +def subprocess_run_stub(): + with mock.patch.object( + webauthn_handler.subprocess, "run", name="fake subprocess.run" + ) as mock_subprocess_run: + yield mock_subprocess_run + + +def test_PluginHandler_is_available(os_get_stub): + test_handler = webauthn_handler.PluginHandler() + + assert test_handler.is_available() is True + + os_get_stub.return_value = None + assert test_handler.is_available() is False + + +GET_ASSERTION_REQUEST = webauthn_types.GetRequest( + origin="fake_origin", + rpid="fake_rpid", + challenge="fake_challenge", + allow_credentials=[webauthn_types.PublicKeyCredentialDescriptor(id="fake_id_1")], +) + + +def test_malformated_get_assertion_response(os_get_stub, subprocess_run_stub): + response_len = struct.pack("/authenticate" + assert creds._ca_cert_path == "/path/to/ca/cert" + assert project == "project_foo" + + +@mock.patch.dict(os.environ) +@mock.patch( + "google.auth._cloud_sdk.get_application_default_credentials_path", autospec=True +) +def test_quota_project_from_environment(get_adc_path): + get_adc_path.return_value = AUTHORIZED_USER_CLOUD_SDK_WITH_QUOTA_PROJECT_ID_FILE + + credentials, _ = _default.default(quota_project_id=None) + assert credentials.quota_project_id == "quota_project_id" + + quota_from_env = "quota_from_env" + os.environ[environment_vars.GOOGLE_CLOUD_QUOTA_PROJECT] = quota_from_env + credentials, _ = _default.default(quota_project_id=None) + assert credentials.quota_project_id == quota_from_env + + explicit_quota = "explicit_quota" + credentials, _ = _default.default(quota_project_id=explicit_quota) + assert credentials.quota_project_id == explicit_quota + + +@mock.patch( + "google.auth.compute_engine._metadata.is_on_gce", return_value=True, autospec=True +) +@mock.patch( + "google.auth.compute_engine._metadata.get_project_id", + return_value="example-project", + autospec=True, +) +@mock.patch.dict(os.environ) +def test_quota_gce_credentials(unused_get, unused_ping): + # No quota + credentials, project_id = _default._get_gce_credentials() + assert project_id == "example-project" + assert credentials.quota_project_id is None + + # Quota from environment + quota_from_env = "quota_from_env" + os.environ[environment_vars.GOOGLE_CLOUD_QUOTA_PROJECT] = quota_from_env + credentials, project_id = _default._get_gce_credentials() + assert credentials.quota_project_id == quota_from_env + + # Explicit quota + explicit_quota = "explicit_quota" + credentials, project_id = _default._get_gce_credentials( + quota_project_id=explicit_quota + ) + assert credentials.quota_project_id == explicit_quota + + +def test_load_credentials_from_file_deprecation_warning(): + with pytest.warns( + DeprecationWarning, match="The load_credentials_from_file method is deprecated" + ): + _default.load_credentials_from_file(SERVICE_ACCOUNT_FILE) + + +def test_load_credentials_from_dict_deprecation_warning(): + with pytest.warns( + DeprecationWarning, match="The load_credentials_from_dict method is deprecated" + ): + _default.load_credentials_from_dict(SERVICE_ACCOUNT_FILE_DATA) + + +@mock.patch("google.auth._cloud_sdk.get_project_id", return_value=None, autospec=True) +@mock.patch("os.path.isfile", return_value=True, autospec=True) +@mock.patch( + "google.auth._cloud_sdk.get_application_default_credentials_path", + return_value=SERVICE_ACCOUNT_FILE, + autospec=True, +) +def test_get_gcloud_sdk_credentials_suppresses_deprecation_warning( + get_adc_path, isfile, get_project_id +): + with warnings.catch_warnings(record=True) as caught_warnings: + warnings.simplefilter("always") + + _default._get_gcloud_sdk_credentials() + + assert not any( + isinstance(w.message, DeprecationWarning) + and "load_credentials_from_file" in str(w.message) + for w in caught_warnings + ) + + +def test_get_explicit_environ_credentials_suppresses_deprecation_warning(monkeypatch): + monkeypatch.setenv(environment_vars.CREDENTIALS, SERVICE_ACCOUNT_FILE) + + with warnings.catch_warnings(record=True) as caught_warnings: + warnings.simplefilter("always") + + _default._get_explicit_environ_credentials() + + assert not any( + isinstance(w.message, DeprecationWarning) + and "load_credentials_from_file" in str(w.message) + for w in caught_warnings + ) diff --git a/packages/google-auth/tests/test__exponential_backoff.py b/packages/google-auth/tests/test__exponential_backoff.py new file mode 100644 index 000000000000..358083edad05 --- /dev/null +++ b/packages/google-auth/tests/test__exponential_backoff.py @@ -0,0 +1,97 @@ +# Copyright 2022 Google Inc. +# +# 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 mock +import pytest # type: ignore + +from google.auth import _exponential_backoff +from google.auth import exceptions + + +@mock.patch("time.sleep", return_value=None) +def test_exponential_backoff(mock_time): + eb = _exponential_backoff.ExponentialBackoff() + curr_wait = eb._current_wait_in_seconds + iteration_count = 0 + + for attempt in eb: + if attempt == 1: + assert mock_time.call_count == 0 + else: + backoff_interval = mock_time.call_args[0][0] + jitter = curr_wait * eb._randomization_factor + + assert (curr_wait - jitter) <= backoff_interval <= (curr_wait + jitter) + assert attempt == iteration_count + 1 + assert eb.backoff_count == iteration_count + 1 + assert eb._current_wait_in_seconds == eb._multiplier**iteration_count + + curr_wait = eb._current_wait_in_seconds + iteration_count += 1 + + assert eb.total_attempts == _exponential_backoff._DEFAULT_RETRY_TOTAL_ATTEMPTS + assert eb.backoff_count == _exponential_backoff._DEFAULT_RETRY_TOTAL_ATTEMPTS + assert iteration_count == _exponential_backoff._DEFAULT_RETRY_TOTAL_ATTEMPTS + assert ( + mock_time.call_count == _exponential_backoff._DEFAULT_RETRY_TOTAL_ATTEMPTS - 1 + ) + + +def test_minimum_total_attempts(): + with pytest.raises(exceptions.InvalidValue): + _exponential_backoff.ExponentialBackoff(total_attempts=0) + with pytest.raises(exceptions.InvalidValue): + _exponential_backoff.ExponentialBackoff(total_attempts=-1) + _exponential_backoff.ExponentialBackoff(total_attempts=1) + + +@pytest.mark.asyncio +@mock.patch("asyncio.sleep", return_value=None) +async def test_exponential_backoff_async(mock_time_async): + eb = _exponential_backoff.AsyncExponentialBackoff() + curr_wait = eb._current_wait_in_seconds + iteration_count = 0 + + # Workaround issue in python 3.9 related to code coverage by adding `# pragma: no branch` + # See https://github.com/googleapis/gapic-generator-python/pull/1174#issuecomment-1025132372 + async for attempt in eb: # pragma: no branch + if attempt == 1: + assert mock_time_async.call_count == 0 + else: + backoff_interval = mock_time_async.call_args[0][0] + jitter = curr_wait * eb._randomization_factor + + assert (curr_wait - jitter) <= backoff_interval <= (curr_wait + jitter) + assert attempt == iteration_count + 1 + assert eb.backoff_count == iteration_count + 1 + assert eb._current_wait_in_seconds == eb._multiplier**iteration_count + + curr_wait = eb._current_wait_in_seconds + iteration_count += 1 + + assert eb.total_attempts == _exponential_backoff._DEFAULT_RETRY_TOTAL_ATTEMPTS + assert eb.backoff_count == _exponential_backoff._DEFAULT_RETRY_TOTAL_ATTEMPTS + assert iteration_count == _exponential_backoff._DEFAULT_RETRY_TOTAL_ATTEMPTS + assert ( + mock_time_async.call_count + == _exponential_backoff._DEFAULT_RETRY_TOTAL_ATTEMPTS - 1 + ) + + +def test_minimum_total_attempts_async(): + with pytest.raises(exceptions.InvalidValue): + _exponential_backoff.AsyncExponentialBackoff(total_attempts=0) + with pytest.raises(exceptions.InvalidValue): + _exponential_backoff.AsyncExponentialBackoff(total_attempts=-1) + _exponential_backoff.AsyncExponentialBackoff(total_attempts=1) diff --git a/packages/google-auth/tests/test__helpers.py b/packages/google-auth/tests/test__helpers.py new file mode 100644 index 000000000000..2aecc0b7edab --- /dev/null +++ b/packages/google-auth/tests/test__helpers.py @@ -0,0 +1,679 @@ +# Copyright 2016 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 datetime +import json +import logging +from unittest import mock +import urllib + +import pytest # type: ignore + +from google.auth import _helpers, exceptions + +# _MOCK_BASE_LOGGER_NAME is the base logger namespace used for testing. +_MOCK_BASE_LOGGER_NAME = "foogle" + +# _MOCK_CHILD_LOGGER_NAME is the child logger namespace used for testing. +_MOCK_CHILD_LOGGER_NAME = "foogle.bar" + + +@pytest.fixture +def logger(): + """Returns a child logger for testing.""" + logger = logging.getLogger(_MOCK_CHILD_LOGGER_NAME) + logger.level = logging.NOTSET + logger.handlers = [] + logger.propagate = True + return logger + + +@pytest.fixture +def base_logger(): + """Returns a child logger for testing.""" + logger = logging.getLogger(_MOCK_BASE_LOGGER_NAME) + logger.level = logging.NOTSET + logger.handlers = [] + logger.propagate = True + return logger + + +@pytest.fixture(autouse=True) +def reset_logging_initialized(): + """Resets the global _LOGGING_INITIALIZED variable before each test.""" + original_state = _helpers._LOGGING_INITIALIZED + _helpers._LOGGING_INITIALIZED = False + yield + _helpers._LOGGING_INITIALIZED = original_state + + +class SourceClass(object): + def func(self): # pragma: NO COVER + """example docstring""" + + +def test_copy_docstring_success(): + def func(): # pragma: NO COVER + pass + + _helpers.copy_docstring(SourceClass)(func) + + assert func.__doc__ == SourceClass.func.__doc__ + + +def test_copy_docstring_conflict(): + def func(): # pragma: NO COVER + """existing docstring""" + pass + + with pytest.raises(ValueError): + _helpers.copy_docstring(SourceClass)(func) + + +def test_copy_docstring_non_existing(): + def func2(): # pragma: NO COVER + pass + + with pytest.raises(AttributeError): + _helpers.copy_docstring(SourceClass)(func2) + + +def test_parse_content_type_plain(): + assert _helpers.parse_content_type("text/html") == "text/html" + assert _helpers.parse_content_type("application/xml") == "application/xml" + assert _helpers.parse_content_type("application/json") == "application/json" + + +def test_parse_content_type_with_parameters(): + content_type_html = "text/html; charset=UTF-8" + content_type_xml = "application/xml; charset=UTF-16; version=1.0" + content_type_json = "application/json; charset=UTF-8; indent=2" + assert _helpers.parse_content_type(content_type_html) == "text/html" + assert _helpers.parse_content_type(content_type_xml) == "application/xml" + assert _helpers.parse_content_type(content_type_json) == "application/json" + + +def test_parse_content_type_missing_or_broken(): + content_type_foo = None + content_type_bar = "" + content_type_baz = "1234" + content_type_qux = " ; charset=UTF-8" + assert _helpers.parse_content_type(content_type_foo) == "text/plain" + assert _helpers.parse_content_type(content_type_bar) == "text/plain" + assert _helpers.parse_content_type(content_type_baz) == "text/plain" + assert _helpers.parse_content_type(content_type_qux) == "text/plain" + + +def test_utcnow(): + assert isinstance(_helpers.utcnow(), datetime.datetime) + + +def test_datetime_to_secs(): + assert _helpers.datetime_to_secs(datetime.datetime(1970, 1, 1)) == 0 + assert _helpers.datetime_to_secs(datetime.datetime(1990, 5, 29)) == 643939200 + + +def test_to_bytes_with_bytes(): + value = b"bytes-val" + assert _helpers.to_bytes(value) == value + + +def test_to_bytes_with_unicode(): + value = "string-val" + encoded_value = b"string-val" + assert _helpers.to_bytes(value) == encoded_value + + +def test_to_bytes_with_nonstring_type(): + with pytest.raises(ValueError): + _helpers.to_bytes(object()) + + +def test_from_bytes_with_unicode(): + value = "bytes-val" + assert _helpers.from_bytes(value) == value + + +def test_from_bytes_with_bytes(): + value = b"string-val" + decoded_value = "string-val" + assert _helpers.from_bytes(value) == decoded_value + + +def test_from_bytes_with_nonstring_type(): + with pytest.raises(ValueError): + _helpers.from_bytes(object()) + + +def _assert_query(url, expected): + parts = urllib.parse.urlsplit(url) + query = urllib.parse.parse_qs(parts.query) + assert query == expected + + +def test_update_query_params_no_params(): + uri = "http://www.google.com" + updated = _helpers.update_query(uri, {"a": "b"}) + assert updated == uri + "?a=b" + + +def test_update_query_existing_params(): + uri = "http://www.google.com?x=y" + updated = _helpers.update_query(uri, {"a": "b", "c": "d&"}) + _assert_query(updated, {"x": ["y"], "a": ["b"], "c": ["d&"]}) + + +def test_update_query_replace_param(): + base_uri = "http://www.google.com" + uri = base_uri + "?x=a" + updated = _helpers.update_query(uri, {"x": "b", "y": "c"}) + _assert_query(updated, {"x": ["b"], "y": ["c"]}) + + +def test_update_query_remove_param(): + base_uri = "http://www.google.com" + uri = base_uri + "?x=a" + updated = _helpers.update_query(uri, {"y": "c"}, remove=["x"]) + _assert_query(updated, {"y": ["c"]}) + + +def test_scopes_to_string(): + cases = [ + ("", ()), + ("", []), + ("", ("",)), + ("", [""]), + ("a", ("a",)), + ("b", ["b"]), + ("a b", ["a", "b"]), + ("a b", ("a", "b")), + ("a b", (s for s in ["a", "b"])), + ] + for expected, case in cases: + assert _helpers.scopes_to_string(case) == expected + + +def test_string_to_scopes(): + cases = [("", []), ("a", ["a"]), ("a b c d e f", ["a", "b", "c", "d", "e", "f"])] + + for case, expected in cases: + assert _helpers.string_to_scopes(case) == expected + + +def test_padded_urlsafe_b64decode(): + cases = [ + ("YQ==", b"a"), + ("YQ", b"a"), + ("YWE=", b"aa"), + ("YWE", b"aa"), + ("YWFhYQ==", b"aaaa"), + ("YWFhYQ", b"aaaa"), + ("YWFhYWE=", b"aaaaa"), + ("YWFhYWE", b"aaaaa"), + ] + + for case, expected in cases: + assert _helpers.padded_urlsafe_b64decode(case) == expected + + +def test_unpadded_urlsafe_b64encode(): + cases = [(b"", b""), (b"a", b"YQ"), (b"aa", b"YWE"), (b"aaa", b"YWFh")] + + for case, expected in cases: + assert _helpers.unpadded_urlsafe_b64encode(case) == expected + + +def test_get_bool_from_env(monkeypatch): + # Test default value when environment variable is not set. + assert _helpers.get_bool_from_env("TEST_VAR") is False + assert _helpers.get_bool_from_env("TEST_VAR", default=True) is True + + # Test true values (case-insensitive) + for true_value in ("true", "True", "TRUE", "1"): + monkeypatch.setenv("TEST_VAR", true_value) + assert _helpers.get_bool_from_env("TEST_VAR") is True + + # Test false values (case-insensitive) + for false_value in ("false", "False", "FALSE", "0"): + monkeypatch.setenv("TEST_VAR", false_value) + assert _helpers.get_bool_from_env("TEST_VAR") is False + + # Test invalid value + monkeypatch.setenv("TEST_VAR", "invalid_value") + with pytest.raises(exceptions.InvalidValue) as excinfo: + _helpers.get_bool_from_env("TEST_VAR") + assert 'must be one of "true", "false", "1", or "0"' in str(excinfo.value) + + # Test empty string value + monkeypatch.setenv("TEST_VAR", "") + with pytest.raises(exceptions.InvalidValue): + _helpers.get_bool_from_env("TEST_VAR") + + +def test_hash_sensitive_info_basic(): + test_data = { + "expires_in": 3599, + "access_token": "access-123", + "scope": "https://www.googleapis.com/auth/test-api", + "token_type": "Bearer", + } + hashed_data = _helpers._hash_sensitive_info(test_data) + assert hashed_data["expires_in"] == 3599 + assert hashed_data["scope"] == "https://www.googleapis.com/auth/test-api" + assert hashed_data["access_token"].startswith("hashed_access_token-") + assert hashed_data["token_type"] == "Bearer" + + +def test_hash_sensitive_info_multiple_sensitive(): + test_data = { + "access_token": "some_long_token", + "id_token": "1234-5678-9012-3456", + "expires_in": 3599, + "token_type": "Bearer", + } + hashed_data = _helpers._hash_sensitive_info(test_data) + assert hashed_data["expires_in"] == 3599 + assert hashed_data["token_type"] == "Bearer" + assert hashed_data["access_token"].startswith("hashed_access_token-") + assert hashed_data["id_token"].startswith("hashed_id_token-") + + +def test_hash_sensitive_info_none_value(): + test_data = {"username": "user3", "secret": None, "normal_data": "abc"} + hashed_data = _helpers._hash_sensitive_info(test_data) + assert hashed_data["secret"] is None + assert hashed_data["normal_data"] == "abc" + + +def test_hash_sensitive_info_non_string_value(): + test_data = {"username": "user4", "access_token": 12345, "normal_data": "def"} + hashed_data = _helpers._hash_sensitive_info(test_data) + assert hashed_data["access_token"].startswith("hashed_access_token-") + assert hashed_data["normal_data"] == "def" + + +def test_hash_sensitive_info_list_value(): + test_data = [ + {"name": "Alice", "access_token": "12345"}, + {"name": "Bob", "client_id": "1141"}, + ] + hashed_data = _helpers._hash_sensitive_info(test_data) + assert hashed_data[0]["access_token"].startswith("hashed_access_token-") + assert hashed_data[1]["client_id"].startswith("hashed_client_id-") + + +def test_hash_sensitive_info_nested_list_value(): + test_data = [{"names": ["Alice", "Bob"], "tokens": [{"access_token": "1234"}]}] + hashed_data = _helpers._hash_sensitive_info(test_data) + assert hashed_data[0]["tokens"][0]["access_token"].startswith( + "hashed_access_token-" + ) + + +def test_hash_sensitive_info_int_value(): + test_data = 123 + hashed_data = _helpers._hash_sensitive_info(test_data) + assert hashed_data == "" + + +def test_hash_sensitive_info_bool_value(): + test_data = True + hashed_data = _helpers._hash_sensitive_info(test_data) + assert hashed_data == "" + + +def test_hash_sensitive_info_byte_value(): + test_data = b"1243" + hashed_data = _helpers._hash_sensitive_info(test_data) + assert hashed_data == "" + + +def test_hash_sensitive_info_empty_dict(): + test_data = {} + hashed_data = _helpers._hash_sensitive_info(test_data) + assert hashed_data == {} + + +def test_hash_value_consistent_hashing(): + value = "test_value" + field_name = "test_field" + hash1 = _helpers._hash_value(value, field_name) + hash2 = _helpers._hash_value(value, field_name) + assert hash1 == hash2 + + +def test_hash_value_different_hashing(): + value1 = "test_value1" + value2 = "test_value2" + field_name = "test_field" + hash1 = _helpers._hash_value(value1, field_name) + hash2 = _helpers._hash_value(value2, field_name) + assert hash1 != hash2 + + +def test_hash_value_none(): + assert _helpers._hash_value(None, "test") is None + + +def test_logger_configured_default(logger): + assert not _helpers._logger_configured(logger) + + +def test_logger_configured_with_handler(logger): + mock_handler = logging.NullHandler() + logger.addHandler(mock_handler) + assert _helpers._logger_configured(logger) + + # Cleanup + logger.removeHandler(mock_handler) + + +def test_logger_configured_with_custom_level(logger): + original_level = logger.level + logger.level = logging.INFO + assert _helpers._logger_configured(logger) + + # Cleanup + logging.level = original_level + + +def test_logger_configured_with_propagate(logger): + original_propagate = logger.propagate + logger.propagate = False + assert _helpers._logger_configured(logger) + + # Cleanup + logger.propagate = original_propagate + + +def test_is_logging_enabled_with_no_level_set(logger, base_logger): + with mock.patch("google.auth._helpers._BASE_LOGGER_NAME", "foogle"): + assert _helpers.is_logging_enabled(logger) is False + + +def test_is_logging_enabled_with_debug_disabled(caplog, logger, base_logger): + with mock.patch("google.auth._helpers._BASE_LOGGER_NAME", _MOCK_BASE_LOGGER_NAME): + caplog.set_level(logging.INFO, logger=_MOCK_CHILD_LOGGER_NAME) + assert _helpers.is_logging_enabled(logger) is False + + +def test_is_logging_enabled_with_debug_enabled(caplog, logger, base_logger): + with mock.patch("google.auth._helpers._BASE_LOGGER_NAME", _MOCK_BASE_LOGGER_NAME): + caplog.set_level(logging.DEBUG, logger=_MOCK_CHILD_LOGGER_NAME) + assert _helpers.is_logging_enabled(logger) + + +def test_is_logging_enabled_with_base_logger_configured_with_info( + caplog, logger, base_logger +): + with mock.patch("google.auth._helpers._BASE_LOGGER_NAME", _MOCK_BASE_LOGGER_NAME): + caplog.set_level(logging.INFO, logger=_MOCK_BASE_LOGGER_NAME) + + base_logger = logging.getLogger(_MOCK_BASE_LOGGER_NAME) + assert not _helpers.is_logging_enabled(base_logger) + assert not _helpers.is_logging_enabled(logger) + + +def test_is_logging_enabled_with_base_logger_configured_with_debug( + caplog, logger, base_logger +): + with mock.patch("google.auth._helpers._BASE_LOGGER_NAME", _MOCK_BASE_LOGGER_NAME): + caplog.set_level(logging.DEBUG, logger=_MOCK_BASE_LOGGER_NAME) + + assert _helpers.is_logging_enabled(base_logger) + assert _helpers.is_logging_enabled(logger) + + +def test_is_logging_enabled_with_base_logger_info_child_logger_debug( + caplog, logger, base_logger +): + with mock.patch("google.auth._helpers._BASE_LOGGER_NAME", _MOCK_BASE_LOGGER_NAME): + caplog.set_level(logging.INFO, logger=_MOCK_BASE_LOGGER_NAME) + caplog.set_level(logging.DEBUG, logger=_MOCK_CHILD_LOGGER_NAME) + + assert not _helpers.is_logging_enabled(base_logger) + assert _helpers.is_logging_enabled(logger) + + +def test_is_logging_enabled_with_base_logger_debug_child_logger_info( + caplog, logger, base_logger +): + with mock.patch("google.auth._helpers._BASE_LOGGER_NAME", _MOCK_BASE_LOGGER_NAME): + caplog.set_level(logging.DEBUG, logger=_MOCK_BASE_LOGGER_NAME) + caplog.set_level(logging.INFO, logger=_MOCK_CHILD_LOGGER_NAME) + + assert _helpers.is_logging_enabled(base_logger) + assert not _helpers.is_logging_enabled(logger) + + +def test_request_log_debug_enabled(logger, caplog, base_logger): + caplog.set_level(logging.DEBUG, logger=_MOCK_CHILD_LOGGER_NAME) + _helpers.request_log( + logger, + "GET", + "http://example.com", + b'{"key": "value"}', + {"Authorization": "Bearer token"}, + ) + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.message == "Making request..." + assert record.httpRequest == { + "method": "GET", + "url": "http://example.com", + "body": {"key": "value"}, + "headers": {"Authorization": "Bearer token"}, + } + + +def test_request_log_plain_text_debug_enabled(logger, caplog, base_logger): + caplog.set_level(logging.DEBUG, logger=_MOCK_CHILD_LOGGER_NAME) + _helpers.request_log( + logger, + "GET", + "http://example.com", + b"This is plain text.", + {"Authorization": "Bearer token", "Content-Type": "text/plain"}, + ) + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.message == "Making request..." + assert record.httpRequest == { + "method": "GET", + "url": "http://example.com", + "body": "", + "headers": {"Authorization": "Bearer token", "Content-Type": "text/plain"}, + } + + +def test_request_log_debug_disabled(logger, caplog, base_logger): + caplog.set_level(logging.INFO, logger=_MOCK_CHILD_LOGGER_NAME) + _helpers.request_log( + logger, + "POST", + "https://api.example.com", + "data", + {"Content-Type": "application/json"}, + ) + assert "Making request: POST https://api.example.com" not in caplog.text + + +def test_response_log_debug_enabled(logger, caplog, base_logger): + caplog.set_level(logging.DEBUG, logger=_MOCK_CHILD_LOGGER_NAME) + _helpers.response_log(logger, {"payload": None}) + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.message == "Response received..." + assert record.httpResponse == "" + + +def test_response_log_debug_disabled(logger, caplog): + caplog.set_level(logging.INFO, logger=_MOCK_CHILD_LOGGER_NAME) + _helpers.response_log(logger, "another_response") + assert "Response received..." not in caplog.text + + +def test_response_log_base_logger_configured(logger, caplog, base_logger): + caplog.set_level(logging.DEBUG, logger=_MOCK_BASE_LOGGER_NAME) + _helpers.response_log(logger, "another_response") + assert "Response received..." in caplog.text + + +def test_response_log_debug_enabled_response_list(logger, caplog, base_logger): + # NOTE: test the response log when response.json() returns a list as per + # https://requests.readthedocs.io/en/latest/api/#requests.Response.json. + class MockResponse: + def json(self): + return ["item1", "item2", "item3"] + + response = MockResponse() + caplog.set_level(logging.DEBUG, logger=_MOCK_CHILD_LOGGER_NAME) + _helpers.response_log(logger, response) + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.message == "Response received..." + assert record.httpResponse == ["", "", ""] + + +def test_parse_request_body_bytes_valid(): + body = b"key1=value1&key2=value2" + expected = {"key1": "value1", "key2": "value2"} + assert ( + _helpers._parse_request_body( + body, content_type="application/x-www-form-urlencoded" + ) + == expected + ) + + +def test_parse_request_body_bytes_empty(): + body = b"" + assert _helpers._parse_request_body(body) == "" + + +def test_parse_request_body_bytes_invalid_encoding(): + body = b"\xff\xfe\xfd" # Invalid UTF-8 sequence + assert _helpers._parse_request_body(body) is None + + +def test_parse_request_body_bytes_malformed_query(): + body = b"key1=value1&key2=value2" # missing equals + expected = {"key1": "value1", "key2": "value2"} + assert ( + _helpers._parse_request_body( + body, content_type="application/x-www-form-urlencoded" + ) + == expected + ) + + +def test_parse_request_body_none(): + assert _helpers._parse_request_body(None) is None + + +def test_parse_request_body_bytes_no_content_type(): + body = b'{"key": "value"}' + expected = {"key": "value"} + assert _helpers._parse_request_body(body) == expected + + +def test_parse_request_body_bytes_content_type_json(): + body = b'{"key": "value"}' + expected = {"key": "value"} + assert ( + _helpers._parse_request_body(body, content_type="application/json") == expected + ) + + +def test_parse_request_body_content_type_urlencoded(): + body = b"key=value" + expected = {"key": "value"} + assert ( + _helpers._parse_request_body( + body, content_type="application/x-www-form-urlencoded" + ) + == expected + ) + + +def test_parse_request_body_bytes_content_type_text(): + body = b"This is plain text." + expected = "This is plain text." + assert _helpers._parse_request_body(body, content_type="text/plain") == expected + + +def test_parse_request_body_content_type_invalid(): + body = b'{"key": "value"}' + assert _helpers._parse_request_body(body, content_type="invalid") is None + + +def test_parse_request_body_other_type(): + assert _helpers._parse_request_body(123) is None + assert _helpers._parse_request_body("string") is None + + +def test_parse_request_body_json_type_error(): + body = b'{"key": "value"}' + with mock.patch("json.loads", side_effect=TypeError): + # json.loads should raise a TypeError, and the function should return the + # original string + assert _helpers._parse_request_body(body, "application/json") == body.decode( + "utf-8" + ) + + +def test_parse_request_body_json_value_error(): + body = b'{"key": "value"}' + content_type = "application/json" + with mock.patch("json.loads", side_effect=ValueError): + # json.loads should raise a ValueError, and the function should return the + # original string + assert _helpers._parse_request_body(body, content_type) == body.decode("utf-8") + + +def test_parse_request_body_json_decode_error(): + body = b'{"key": "value"}' + content_type = "application/json" + with mock.patch("json.loads", side_effect=json.JSONDecodeError("msg", "doc", 0)): + # json.loads should raise a JSONDecodeError, and the function should return the + # original string + assert _helpers._parse_request_body(body, content_type) == body.decode("utf-8") + + +def test_parse_response_json_valid(): + class MockResponse: + def json(self): + return {"data": "test"} + + response = MockResponse() + expected = {"data": "test"} + assert _helpers._parse_response(response) == expected + + +def test_parse_response_json_invalid(): + class MockResponse: + def json(self): + raise json.JSONDecodeError("msg", "doc", 0) + + response = MockResponse() + assert _helpers._parse_response(response) is None + + +def test_parse_response_no_json_method(): + response = "plain text" + assert _helpers._parse_response(response) is None + + +def test_parse_response_none(): + assert _helpers._parse_response(None) is None diff --git a/packages/google-auth/tests/test__oauth2client.py b/packages/google-auth/tests/test__oauth2client.py new file mode 100644 index 000000000000..0639be102675 --- /dev/null +++ b/packages/google-auth/tests/test__oauth2client.py @@ -0,0 +1,192 @@ +# Copyright 2016 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 datetime +import importlib +import os +import sys + +import mock +import pytest # type: ignore + +try: + import oauth2client.client # type: ignore + import oauth2client.contrib.gce # type: ignore + import oauth2client.service_account # type: ignore +except ImportError: # pragma: NO COVER + pytest.skip( + "Skipping oauth2client tests since oauth2client is not installed.", + allow_module_level=True, + ) + +from google.auth import _oauth2client + + +DATA_DIR = os.path.join(os.path.dirname(__file__), "data") +SERVICE_ACCOUNT_JSON_FILE = os.path.join(DATA_DIR, "service_account.json") + + +def test__convert_oauth2_credentials(): + old_credentials = oauth2client.client.OAuth2Credentials( + "access_token", + "client_id", + "client_secret", + "refresh_token", + datetime.datetime.min, + "token_uri", + "user_agent", + scopes="one two", + ) + + new_credentials = _oauth2client._convert_oauth2_credentials(old_credentials) + + assert new_credentials.token == old_credentials.access_token + assert new_credentials._refresh_token == old_credentials.refresh_token + assert new_credentials._client_id == old_credentials.client_id + assert new_credentials._client_secret == old_credentials.client_secret + assert new_credentials._token_uri == old_credentials.token_uri + assert new_credentials.scopes == old_credentials.scopes + + +def test__convert_service_account_credentials(): + old_class = oauth2client.service_account.ServiceAccountCredentials + old_credentials = old_class.from_json_keyfile_name(SERVICE_ACCOUNT_JSON_FILE) + + new_credentials = _oauth2client._convert_service_account_credentials( + old_credentials + ) + + assert ( + new_credentials.service_account_email == old_credentials.service_account_email + ) + assert new_credentials._signer.key_id == old_credentials._private_key_id + assert new_credentials._token_uri == old_credentials.token_uri + + +def test__convert_service_account_credentials_with_jwt(): + old_class = oauth2client.service_account._JWTAccessCredentials + old_credentials = old_class.from_json_keyfile_name(SERVICE_ACCOUNT_JSON_FILE) + + new_credentials = _oauth2client._convert_service_account_credentials( + old_credentials + ) + + assert ( + new_credentials.service_account_email == old_credentials.service_account_email + ) + assert new_credentials._signer.key_id == old_credentials._private_key_id + assert new_credentials._token_uri == old_credentials.token_uri + + +def test__convert_gce_app_assertion_credentials(): + old_credentials = oauth2client.contrib.gce.AppAssertionCredentials( + email="some_email" + ) + + new_credentials = _oauth2client._convert_gce_app_assertion_credentials( + old_credentials + ) + + assert ( + new_credentials.service_account_email == old_credentials.service_account_email + ) + + +@pytest.fixture +def mock_oauth2client_gae_imports(mock_non_existent_module): + mock_non_existent_module("google.appengine.api.app_identity") + mock_non_existent_module("google.appengine.ext.ndb") + mock_non_existent_module("google.appengine.ext.webapp.util") + mock_non_existent_module("webapp2") + + +@mock.patch("google.auth.app_engine.app_identity") +def test__convert_appengine_app_assertion_credentials( + app_identity, mock_oauth2client_gae_imports +): + # `oauth2client` requires `cgi` which was removed in Python 3.13 + # See https://github.com/googleapis/oauth2client/blob/50d20532a748f18e53f7d24ccbe6647132c979a9/oauth2client/contrib/appengine.py#L20 + # oauth2client is no longer being updated so this test must be skipped on newer Python Runtimes + if sys.version_info >= (3, 13): # pragma: NO COVER + pytest.skip( + "Skipping test for Python 3.13+ due to oauth2client incompatibility." + ) + + import oauth2client.contrib.appengine # type: ignore + + service_account_id = "service_account_id" + old_credentials = oauth2client.contrib.appengine.AppAssertionCredentials( + scope="one two", service_account_id=service_account_id + ) + + new_credentials = _oauth2client._convert_appengine_app_assertion_credentials( + old_credentials + ) + + assert new_credentials.scopes == ["one", "two"] + assert new_credentials._service_account_id == old_credentials.service_account_id + + +class FakeCredentials(object): + pass + + +def test_convert_success(): + convert_function = mock.Mock(spec=["__call__"]) + conversion_map_patch = mock.patch.object( + _oauth2client, "_CLASS_CONVERSION_MAP", {FakeCredentials: convert_function} + ) + credentials = FakeCredentials() + + with conversion_map_patch: + result = _oauth2client.convert(credentials) + + convert_function.assert_called_once_with(credentials) + assert result == convert_function.return_value + + +def test_convert_not_found(): + with pytest.raises(ValueError) as excinfo: + _oauth2client.convert("a string is not a real credentials class") + + assert excinfo.match("Unable to convert") + + +@pytest.fixture +def reset__oauth2client_module(): + """Reloads the _oauth2client module after a test.""" + importlib.reload(_oauth2client) + + +def test_import_has_app_engine( + mock_oauth2client_gae_imports, reset__oauth2client_module +): + # `oauth2client` requires `cgi` which was removed in Python 3.13 + # See https://github.com/googleapis/oauth2client/blob/50d20532a748f18e53f7d24ccbe6647132c979a9/oauth2client/contrib/appengine.py#L20 + # oauth2client is no longer being updated so this test must be skipped on newer Python Runtimes + if sys.version_info >= (3, 13): # pragma: NO COVER + pytest.skip( + "Skipping test for Python 3.13+ due to oauth2client incompatibility." + ) + + importlib.reload(_oauth2client) + assert _oauth2client._HAS_APPENGINE + + +def test_import_without_oauth2client(monkeypatch, reset__oauth2client_module): + monkeypatch.setitem(sys.modules, "oauth2client", None) + with pytest.raises(ImportError) as excinfo: + importlib.reload(_oauth2client) + + assert excinfo.match("oauth2client") diff --git a/packages/google-auth/tests/test__refresh_worker.py b/packages/google-auth/tests/test__refresh_worker.py new file mode 100644 index 000000000000..c25965d10b93 --- /dev/null +++ b/packages/google-auth/tests/test__refresh_worker.py @@ -0,0 +1,159 @@ +# Copyright 2023 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 pickle +import random +import threading +import time + +import mock +import pytest # type: ignore + +from google.auth import _refresh_worker, credentials, exceptions + +MAIN_THREAD_SLEEP_MS = 100 / 1000 + + +class MockCredentialsImpl(credentials.Credentials): + def __init__(self, sleep_seconds=None): + self.refresh_count = 0 + self.token = None + self.sleep_seconds = sleep_seconds if sleep_seconds else None + + def refresh(self, request): + if self.sleep_seconds: + time.sleep(self.sleep_seconds) + self.token = request + self.refresh_count += 1 + + +@pytest.fixture +def test_thread_count(): + return 25 + + +def _cred_spinlock(cred): + while cred.token is None: # pragma: NO COVER + time.sleep(MAIN_THREAD_SLEEP_MS) + + +def test_invalid_start_refresh(): + w = _refresh_worker.RefreshThreadManager() + with pytest.raises(exceptions.InvalidValue): + w.start_refresh(None, None) + + +def test_start_refresh(): + w = _refresh_worker.RefreshThreadManager() + cred = MockCredentialsImpl() + request = mock.MagicMock() + assert w.start_refresh(cred, request) + + assert w._worker is not None + + _cred_spinlock(cred) + + assert cred.token == request + assert cred.refresh_count == 1 + + +def test_nonblocking_start_refresh(): + w = _refresh_worker.RefreshThreadManager() + cred = MockCredentialsImpl(sleep_seconds=1) + request = mock.MagicMock() + assert w.start_refresh(cred, request) + + assert w._worker is not None + assert not cred.token + assert cred.refresh_count == 0 + + +def test_multiple_refreshes_multiple_workers(test_thread_count): + w = _refresh_worker.RefreshThreadManager() + cred = MockCredentialsImpl() + request = mock.MagicMock() + + def _thread_refresh(): + time.sleep(random.randrange(0, 5)) + assert w.start_refresh(cred, request) + + threads = [ + threading.Thread(target=_thread_refresh) for _ in range(test_thread_count) + ] + for t in threads: + t.start() + + _cred_spinlock(cred) + + assert cred.token == request + # There is a chance only one thread has enough time to perform a refresh. + # Generally multiple threads will have time to perform a refresh + assert cred.refresh_count > 0 + + +def test_refresh_error(): + w = _refresh_worker.RefreshThreadManager() + cred = mock.MagicMock() + request = mock.MagicMock() + + cred.refresh.side_effect = exceptions.RefreshError("Failed to refresh") + + assert w.start_refresh(cred, request) + + while w._worker._error_info is None: # pragma: NO COVER + time.sleep(MAIN_THREAD_SLEEP_MS) + + assert w._worker is not None + assert isinstance(w._worker._error_info, exceptions.RefreshError) + + +def test_refresh_error_call_refresh_again(): + w = _refresh_worker.RefreshThreadManager() + cred = mock.MagicMock() + request = mock.MagicMock() + + cred.refresh.side_effect = exceptions.RefreshError("Failed to refresh") + + assert w.start_refresh(cred, request) + + while w._worker._error_info is None: # pragma: NO COVER + time.sleep(MAIN_THREAD_SLEEP_MS) + + assert not w.start_refresh(cred, request) + + +def test_refresh_dead_worker(): + cred = MockCredentialsImpl() + request = mock.MagicMock() + + w = _refresh_worker.RefreshThreadManager() + w._worker = None + + w.start_refresh(cred, request) + + _cred_spinlock(cred) + + assert cred.token == request + assert cred.refresh_count == 1 + + +def test_pickle(): + w = _refresh_worker.RefreshThreadManager() + # For some reason isinstance cannot interpret threading.Lock as a type. + assert w._lock is not None + + pickled_manager = pickle.dumps(w) + manager = pickle.loads(pickled_manager) + assert isinstance(manager, _refresh_worker.RefreshThreadManager) + assert manager._lock is not None diff --git a/packages/google-auth/tests/test__service_account_info.py b/packages/google-auth/tests/test__service_account_info.py new file mode 100644 index 000000000000..7e836861e4a7 --- /dev/null +++ b/packages/google-auth/tests/test__service_account_info.py @@ -0,0 +1,109 @@ +# Copyright 2016 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 json +import os + +import pytest # type: ignore + +from google.auth import _service_account_info +from google.auth import crypt + + +DATA_DIR = os.path.join(os.path.dirname(__file__), "data") +SERVICE_ACCOUNT_JSON_FILE = os.path.join(DATA_DIR, "service_account.json") +GDCH_SERVICE_ACCOUNT_ES256_JSON_FILE = os.path.join( + DATA_DIR, "gdch_service_account.json" +) +GDCH_SERVICE_ACCOUNT_ES384_JSON_FILE = os.path.join( + DATA_DIR, "es384_service_account.json" +) + +with open(SERVICE_ACCOUNT_JSON_FILE, "r") as fh: + SERVICE_ACCOUNT_INFO = json.load(fh) + +with open(GDCH_SERVICE_ACCOUNT_ES256_JSON_FILE, "r") as fh: + GDCH_SERVICE_ACCOUNT_ES256_INFO = json.load(fh) + +with open(GDCH_SERVICE_ACCOUNT_ES384_JSON_FILE, "r") as fh: + GDCH_SERVICE_ACCOUNT_ES384_INFO = json.load(fh) + + +def test_from_dict(): + signer = _service_account_info.from_dict(SERVICE_ACCOUNT_INFO) + assert isinstance(signer, crypt.RSASigner) + assert signer.key_id == SERVICE_ACCOUNT_INFO["private_key_id"] + + +def test_from_dict_es256_signer(): + signer = _service_account_info.from_dict( + GDCH_SERVICE_ACCOUNT_ES256_INFO, use_rsa_signer=False + ) + assert isinstance(signer, crypt.EsSigner) + assert signer.key_id == GDCH_SERVICE_ACCOUNT_ES256_INFO["private_key_id"] + + +def test_from_dict_es384_signer(): + signer = _service_account_info.from_dict( + GDCH_SERVICE_ACCOUNT_ES384_INFO, use_rsa_signer=False + ) + assert isinstance(signer, crypt.EsSigner) + assert signer.key_id == GDCH_SERVICE_ACCOUNT_ES384_INFO["private_key_id"] + assert signer.algorithm == "ES384" + + +def test_from_dict_bad_private_key(): + info = SERVICE_ACCOUNT_INFO.copy() + info["private_key"] = "garbage" + + with pytest.raises(ValueError) as excinfo: + _service_account_info.from_dict(info) + + assert excinfo.match(r"(?i)(key|PEM)") + + +def test_from_dict_bad_format(): + with pytest.raises(ValueError) as excinfo: + _service_account_info.from_dict({}, require=("meep",)) + + assert excinfo.match(r"missing fields") + + +def test_from_filename(): + info, signer = _service_account_info.from_filename(SERVICE_ACCOUNT_JSON_FILE) + + for key, value in SERVICE_ACCOUNT_INFO.items(): + assert info[key] == value + + assert isinstance(signer, crypt.RSASigner) + assert signer.key_id == SERVICE_ACCOUNT_INFO["private_key_id"] + + +def test_from_filename_es256_signer(): + _, signer = _service_account_info.from_filename( + GDCH_SERVICE_ACCOUNT_ES256_JSON_FILE, use_rsa_signer=False + ) + + assert isinstance(signer, crypt.EsSigner) + assert signer.key_id == GDCH_SERVICE_ACCOUNT_ES256_INFO["private_key_id"] + + +def test_from_filename_es384_signer(): + _, signer = _service_account_info.from_filename( + GDCH_SERVICE_ACCOUNT_ES384_JSON_FILE, use_rsa_signer=False + ) + + assert isinstance(signer, crypt.EsSigner) + assert signer.key_id == GDCH_SERVICE_ACCOUNT_ES384_INFO["private_key_id"] + assert signer.algorithm == "ES384" diff --git a/packages/google-auth/tests/test_api_key.py b/packages/google-auth/tests/test_api_key.py new file mode 100644 index 000000000000..9ba7b1426b1e --- /dev/null +++ b/packages/google-auth/tests/test_api_key.py @@ -0,0 +1,45 @@ +# 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. + +import pytest # type: ignore + +from google.auth import api_key + + +def test_credentials_constructor(): + with pytest.raises(ValueError) as excinfo: + api_key.Credentials("") + + assert excinfo.match(r"Token must be a non-empty API key string") + + +def test_expired_and_valid(): + credentials = api_key.Credentials("api-key") + + assert credentials.valid + assert credentials.token == "api-key" + assert not credentials.expired + + credentials.refresh(None) + assert credentials.valid + assert credentials.token == "api-key" + assert not credentials.expired + + +def test_before_request(): + credentials = api_key.Credentials("api-key") + headers = {} + + credentials.before_request(None, "http://example.com", "GET", headers) + assert headers["x-goog-api-key"] == "api-key" diff --git a/packages/google-auth/tests/test_app_engine.py b/packages/google-auth/tests/test_app_engine.py new file mode 100644 index 000000000000..ca085bd69854 --- /dev/null +++ b/packages/google-auth/tests/test_app_engine.py @@ -0,0 +1,217 @@ +# Copyright 2016 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 datetime + +import mock +import pytest # type: ignore + +from google.auth import app_engine + + +class _AppIdentityModule(object): + """The interface of the App Idenity app engine module. + See https://cloud.google.com/appengine/docs/standard/python/refdocs + /google.appengine.api.app_identity.app_identity + """ + + def get_application_id(self): + raise NotImplementedError() + + def sign_blob(self, bytes_to_sign, deadline=None): + raise NotImplementedError() + + def get_service_account_name(self, deadline=None): + raise NotImplementedError() + + def get_access_token(self, scopes, service_account_id=None): + raise NotImplementedError() + + +@pytest.fixture +def app_identity(monkeypatch): + """Mocks the app_identity module for google.auth.app_engine.""" + app_identity_module = mock.create_autospec(_AppIdentityModule, instance=True) + monkeypatch.setattr(app_engine, "app_identity", app_identity_module) + yield app_identity_module + + +def test_get_project_id(app_identity): + app_identity.get_application_id.return_value = mock.sentinel.project + assert app_engine.get_project_id() == mock.sentinel.project + + +@mock.patch.object(app_engine, "app_identity", new=None) +def test_get_project_id_missing_apis(): + with pytest.raises(EnvironmentError) as excinfo: + assert app_engine.get_project_id() + + assert excinfo.match(r"App Engine APIs are not available") + + +class TestSigner(object): + def test_key_id(self, app_identity): + app_identity.sign_blob.return_value = ( + mock.sentinel.key_id, + mock.sentinel.signature, + ) + + signer = app_engine.Signer() + + assert signer.key_id is None + + def test_sign(self, app_identity): + app_identity.sign_blob.return_value = ( + mock.sentinel.key_id, + mock.sentinel.signature, + ) + + signer = app_engine.Signer() + to_sign = b"123" + + signature = signer.sign(to_sign) + + assert signature == mock.sentinel.signature + app_identity.sign_blob.assert_called_with(to_sign) + + +class TestCredentials(object): + @mock.patch.object(app_engine, "app_identity", new=None) + def test_missing_apis(self): + with pytest.raises(EnvironmentError) as excinfo: + app_engine.Credentials() + + assert excinfo.match(r"App Engine APIs are not available") + + def test_default_state(self, app_identity): + credentials = app_engine.Credentials() + + # Not token acquired yet + assert not credentials.valid + # Expiration hasn't been set yet + assert not credentials.expired + # Scopes are required + assert not credentials.scopes + assert not credentials.default_scopes + assert credentials.requires_scopes + assert not credentials.quota_project_id + + def test_with_scopes(self, app_identity): + credentials = app_engine.Credentials() + + assert not credentials.scopes + assert credentials.requires_scopes + + scoped_credentials = credentials.with_scopes(["email"]) + + assert scoped_credentials.has_scopes(["email"]) + assert not scoped_credentials.requires_scopes + + def test_with_default_scopes(self, app_identity): + credentials = app_engine.Credentials() + + assert not credentials.scopes + assert not credentials.default_scopes + assert credentials.requires_scopes + + scoped_credentials = credentials.with_scopes( + scopes=None, default_scopes=["email"] + ) + + assert scoped_credentials.has_scopes(["email"]) + assert not scoped_credentials.requires_scopes + + def test_with_quota_project(self, app_identity): + credentials = app_engine.Credentials() + + assert not credentials.scopes + assert not credentials.quota_project_id + + quota_project_creds = credentials.with_quota_project("project-foo") + + assert quota_project_creds.quota_project_id == "project-foo" + + def test_service_account_email_implicit(self, app_identity): + app_identity.get_service_account_name.return_value = ( + mock.sentinel.service_account_email + ) + credentials = app_engine.Credentials() + + assert credentials.service_account_email == mock.sentinel.service_account_email + assert app_identity.get_service_account_name.called + + def test_service_account_email_explicit(self, app_identity): + credentials = app_engine.Credentials( + service_account_id=mock.sentinel.service_account_email + ) + + assert credentials.service_account_email == mock.sentinel.service_account_email + assert not app_identity.get_service_account_name.called + + @mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) + def test_refresh(self, utcnow, app_identity): + token = "token" + ttl = 643942923 + app_identity.get_access_token.return_value = token, ttl + credentials = app_engine.Credentials( + scopes=["email"], default_scopes=["profile"] + ) + + credentials.refresh(None) + + app_identity.get_access_token.assert_called_with( + credentials.scopes, credentials._service_account_id + ) + assert credentials.token == token + assert credentials.expiry == datetime.datetime(1990, 5, 29, 1, 2, 3) + assert credentials.valid + assert not credentials.expired + + @mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) + def test_refresh_with_default_scopes(self, utcnow, app_identity): + token = "token" + ttl = 643942923 + app_identity.get_access_token.return_value = token, ttl + credentials = app_engine.Credentials(default_scopes=["email"]) + + credentials.refresh(None) + + app_identity.get_access_token.assert_called_with( + credentials.default_scopes, credentials._service_account_id + ) + assert credentials.token == token + assert credentials.expiry == datetime.datetime(1990, 5, 29, 1, 2, 3) + assert credentials.valid + assert not credentials.expired + + def test_sign_bytes(self, app_identity): + app_identity.sign_blob.return_value = ( + mock.sentinel.key_id, + mock.sentinel.signature, + ) + credentials = app_engine.Credentials() + to_sign = b"123" + + signature = credentials.sign_bytes(to_sign) + + assert signature == mock.sentinel.signature + app_identity.sign_blob.assert_called_with(to_sign) + + def test_signer(self, app_identity): + credentials = app_engine.Credentials() + assert isinstance(credentials.signer, app_engine.Signer) + + def test_signer_email(self, app_identity): + credentials = app_engine.Credentials() + assert credentials.signer_email == credentials.service_account_email diff --git a/packages/google-auth/tests/test_aws.py b/packages/google-auth/tests/test_aws.py new file mode 100644 index 000000000000..5aa979c859e1 --- /dev/null +++ b/packages/google-auth/tests/test_aws.py @@ -0,0 +1,2464 @@ +# 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 datetime +import http.client as http_client +import json +import os +import urllib.parse + +import mock +import pytest # type: ignore + +from google.auth import _helpers, external_account +from google.auth import aws +from google.auth import environment_vars +from google.auth import exceptions +from google.auth import transport +from google.auth.credentials import DEFAULT_UNIVERSE_DOMAIN + +IMPERSONATE_ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE = ( + "gl-python/3.7 auth/1.1 auth-request-type/at cred-type/imp" +) + +LANG_LIBRARY_METRICS_HEADER_VALUE = "gl-python/3.7 auth/1.1" + +CLIENT_ID = "username" +CLIENT_SECRET = "password" +# Base64 encoding of "username:password". +BASIC_AUTH_ENCODING = "dXNlcm5hbWU6cGFzc3dvcmQ=" +SERVICE_ACCOUNT_EMAIL = "service-1234@service-name.iam.gserviceaccount.com" +SERVICE_ACCOUNT_IMPERSONATION_URL_BASE = ( + "https://us-east1-iamcredentials.googleapis.com" +) +SERVICE_ACCOUNT_IMPERSONATION_URL_ROUTE = ( + "/v1/projects/-/serviceAccounts/{}:generateAccessToken".format( + SERVICE_ACCOUNT_EMAIL + ) +) +SERVICE_ACCOUNT_IMPERSONATION_URL = ( + SERVICE_ACCOUNT_IMPERSONATION_URL_BASE + SERVICE_ACCOUNT_IMPERSONATION_URL_ROUTE +) +QUOTA_PROJECT_ID = "QUOTA_PROJECT_ID" +SCOPES = ["scope1", "scope2"] +TOKEN_URL = "https://sts.googleapis.com/v1/token" +TOKEN_INFO_URL = "https://sts.googleapis.com/v1/introspect" +SUBJECT_TOKEN_TYPE = "urn:ietf:params:aws:token-type:aws4_request" +AUDIENCE = "//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID" +REGION_URL = "http://169.254.169.254/latest/meta-data/placement/availability-zone" +IMDSV2_SESSION_TOKEN_URL = "http://169.254.169.254/latest/api/token" +SECURITY_CREDS_URL = "http://169.254.169.254/latest/meta-data/iam/security-credentials" +REGION_URL_IPV6 = "http://[fd00:ec2::254]/latest/meta-data/placement/availability-zone" +IMDSV2_SESSION_TOKEN_URL_IPV6 = "http://[fd00:ec2::254]/latest/api/token" +SECURITY_CREDS_URL_IPV6 = ( + "http://[fd00:ec2::254]/latest/meta-data/iam/security-credentials" +) +CRED_VERIFICATION_URL = ( + "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15" +) +# Sample fictitious AWS security credentials to be used with tests that require a session token. +ACCESS_KEY_ID = "AKIAIOSFODNN7EXAMPLE" +SECRET_ACCESS_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" +TOKEN = "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKwRcOIfrRh3c/LTo6UDdyJwOOvEVPvLXCrrrUtdnniCEXAMPLE/IvU1dYUg2RVAJBanLiHb4IgRmpRV3zrkuWJOgQs8IZZaIv2BXIa2R4OlgkBN9bkUDNCJiBeb/AXlzBBko7b15fjrBs2+cTQtpZ3CYWFXG8C5zqx37wnOE49mRl/+OtkIKGO7fAE" +# To avoid json.dumps() differing behavior from one version to other, +# the JSON payload is hardcoded. +REQUEST_PARAMS = '{"KeySchema":[{"KeyType":"HASH","AttributeName":"Id"}],"TableName":"TestTable","AttributeDefinitions":[{"AttributeName":"Id","AttributeType":"S"}],"ProvisionedThroughput":{"WriteCapacityUnits":5,"ReadCapacityUnits":5}}' +# Each tuple contains the following entries: +# region, time, credentials, original_request, signed_request + +VALID_TOKEN_URLS = [ + "https://sts.googleapis.com", + "https://us-east-1.sts.googleapis.com", + "https://US-EAST-1.sts.googleapis.com", + "https://sts.us-east-1.googleapis.com", + "https://sts.US-WEST-1.googleapis.com", + "https://us-east-1-sts.googleapis.com", + "https://US-WEST-1-sts.googleapis.com", + "https://us-west-1-sts.googleapis.com/path?query", + "https://sts-us-east-1.p.googleapis.com", +] +INVALID_TOKEN_URLS = [ + "https://iamcredentials.googleapis.com", + "sts.googleapis.com", + "https://", + "http://sts.googleapis.com", + "https://st.s.googleapis.com", + "https://us-eas\t-1.sts.googleapis.com", + "https:/us-east-1.sts.googleapis.com", + "https://US-WE/ST-1-sts.googleapis.com", + "https://sts-us-east-1.googleapis.com", + "https://sts-US-WEST-1.googleapis.com", + "testhttps://us-east-1.sts.googleapis.com", + "https://us-east-1.sts.googleapis.comevil.com", + "https://us-east-1.us-east-1.sts.googleapis.com", + "https://us-ea.s.t.sts.googleapis.com", + "https://sts.googleapis.comevil.com", + "hhttps://us-east-1.sts.googleapis.com", + "https://us- -1.sts.googleapis.com", + "https://-sts.googleapis.com", + "https://us-east-1.sts.googleapis.com.evil.com", + "https://sts.pgoogleapis.com", + "https://p.googleapis.com", + "https://sts.p.com", + "http://sts.p.googleapis.com", + "https://xyz-sts.p.googleapis.com", + "https://sts-xyz.123.p.googleapis.com", + "https://sts-xyz.p1.googleapis.com", + "https://sts-xyz.p.foo.com", + "https://sts-xyz.p.foo.googleapis.com", +] +VALID_SERVICE_ACCOUNT_IMPERSONATION_URLS = [ + "https://iamcredentials.googleapis.com", + "https://us-east-1.iamcredentials.googleapis.com", + "https://US-EAST-1.iamcredentials.googleapis.com", + "https://iamcredentials.us-east-1.googleapis.com", + "https://iamcredentials.US-WEST-1.googleapis.com", + "https://us-east-1-iamcredentials.googleapis.com", + "https://US-WEST-1-iamcredentials.googleapis.com", + "https://us-west-1-iamcredentials.googleapis.com/path?query", + "https://iamcredentials-us-east-1.p.googleapis.com", +] +INVALID_SERVICE_ACCOUNT_IMPERSONATION_URLS = [ + "https://sts.googleapis.com", + "iamcredentials.googleapis.com", + "https://", + "http://iamcredentials.googleapis.com", + "https://iamcre.dentials.googleapis.com", + "https://us-eas\t-1.iamcredentials.googleapis.com", + "https:/us-east-1.iamcredentials.googleapis.com", + "https://US-WE/ST-1-iamcredentials.googleapis.com", + "https://iamcredentials-us-east-1.googleapis.com", + "https://iamcredentials-US-WEST-1.googleapis.com", + "testhttps://us-east-1.iamcredentials.googleapis.com", + "https://us-east-1.iamcredentials.googleapis.comevil.com", + "https://us-east-1.us-east-1.iamcredentials.googleapis.com", + "https://us-ea.s.t.iamcredentials.googleapis.com", + "https://iamcredentials.googleapis.comevil.com", + "hhttps://us-east-1.iamcredentials.googleapis.com", + "https://us- -1.iamcredentials.googleapis.com", + "https://-iamcredentials.googleapis.com", + "https://us-east-1.iamcredentials.googleapis.com.evil.com", + "https://iamcredentials.pgoogleapis.com", + "https://p.googleapis.com", + "https://iamcredentials.p.com", + "http://iamcredentials.p.googleapis.com", + "https://xyz-iamcredentials.p.googleapis.com", + "https://iamcredentials-xyz.123.p.googleapis.com", + "https://iamcredentials-xyz.p1.googleapis.com", + "https://iamcredentials-xyz.p.foo.com", + "https://iamcredentials-xyz.p.foo.googleapis.com", +] +TEST_FIXTURES = [ + # GET request (AWS botocore tests). + # https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-vanilla.req + # https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-vanilla.sreq + ( + "us-east-1", + "2011-09-09T23:36:00Z", + { + "access_key_id": "AKIDEXAMPLE", + "secret_access_key": "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + }, + { + "method": "GET", + "url": "https://host.foo.com", + "headers": {"date": "Mon, 09 Sep 2011 23:36:00 GMT"}, + }, + { + "url": "https://host.foo.com", + "method": "GET", + "headers": { + "Authorization": "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/aws4_request, SignedHeaders=date;host, Signature=b27ccfbfa7df52a200ff74193ca6e32d4b48b8856fab7ebf1c595d0670a7e470", + "host": "host.foo.com", + "date": "Mon, 09 Sep 2011 23:36:00 GMT", + }, + }, + ), + # GET request with relative path (AWS botocore tests). + # https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-relative-relative.req + # https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-relative-relative.sreq + ( + "us-east-1", + "2011-09-09T23:36:00Z", + { + "access_key_id": "AKIDEXAMPLE", + "secret_access_key": "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + }, + { + "method": "GET", + "url": "https://host.foo.com/foo/bar/../..", + "headers": {"date": "Mon, 09 Sep 2011 23:36:00 GMT"}, + }, + { + "url": "https://host.foo.com/foo/bar/../..", + "method": "GET", + "headers": { + "Authorization": "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/aws4_request, SignedHeaders=date;host, Signature=b27ccfbfa7df52a200ff74193ca6e32d4b48b8856fab7ebf1c595d0670a7e470", + "host": "host.foo.com", + "date": "Mon, 09 Sep 2011 23:36:00 GMT", + }, + }, + ), + # GET request with /./ path (AWS botocore tests). + # https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-slash-dot-slash.req + # https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-slash-dot-slash.sreq + ( + "us-east-1", + "2011-09-09T23:36:00Z", + { + "access_key_id": "AKIDEXAMPLE", + "secret_access_key": "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + }, + { + "method": "GET", + "url": "https://host.foo.com/./", + "headers": {"date": "Mon, 09 Sep 2011 23:36:00 GMT"}, + }, + { + "url": "https://host.foo.com/./", + "method": "GET", + "headers": { + "Authorization": "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/aws4_request, SignedHeaders=date;host, Signature=b27ccfbfa7df52a200ff74193ca6e32d4b48b8856fab7ebf1c595d0670a7e470", + "host": "host.foo.com", + "date": "Mon, 09 Sep 2011 23:36:00 GMT", + }, + }, + ), + # GET request with pointless dot path (AWS botocore tests). + # https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-slash-pointless-dot.req + # https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-slash-pointless-dot.sreq + ( + "us-east-1", + "2011-09-09T23:36:00Z", + { + "access_key_id": "AKIDEXAMPLE", + "secret_access_key": "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + }, + { + "method": "GET", + "url": "https://host.foo.com/./foo", + "headers": {"date": "Mon, 09 Sep 2011 23:36:00 GMT"}, + }, + { + "url": "https://host.foo.com/./foo", + "method": "GET", + "headers": { + "Authorization": "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/aws4_request, SignedHeaders=date;host, Signature=910e4d6c9abafaf87898e1eb4c929135782ea25bb0279703146455745391e63a", + "host": "host.foo.com", + "date": "Mon, 09 Sep 2011 23:36:00 GMT", + }, + }, + ), + # GET request with utf8 path (AWS botocore tests). + # https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-utf8.req + # https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-utf8.sreq + ( + "us-east-1", + "2011-09-09T23:36:00Z", + { + "access_key_id": "AKIDEXAMPLE", + "secret_access_key": "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + }, + { + "method": "GET", + "url": "https://host.foo.com/%E1%88%B4", + "headers": {"date": "Mon, 09 Sep 2011 23:36:00 GMT"}, + }, + { + "url": "https://host.foo.com/%E1%88%B4", + "method": "GET", + "headers": { + "Authorization": "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/aws4_request, SignedHeaders=date;host, Signature=8d6634c189aa8c75c2e51e106b6b5121bed103fdb351f7d7d4381c738823af74", + "host": "host.foo.com", + "date": "Mon, 09 Sep 2011 23:36:00 GMT", + }, + }, + ), + # GET request with duplicate query key (AWS botocore tests). + # https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-vanilla-query-order-key-case.req + # https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-vanilla-query-order-key-case.sreq + ( + "us-east-1", + "2011-09-09T23:36:00Z", + { + "access_key_id": "AKIDEXAMPLE", + "secret_access_key": "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + }, + { + "method": "GET", + "url": "https://host.foo.com/?foo=Zoo&foo=aha", + "headers": {"date": "Mon, 09 Sep 2011 23:36:00 GMT"}, + }, + { + "url": "https://host.foo.com/?foo=Zoo&foo=aha", + "method": "GET", + "headers": { + "Authorization": "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/aws4_request, SignedHeaders=date;host, Signature=be7148d34ebccdc6423b19085378aa0bee970bdc61d144bd1a8c48c33079ab09", + "host": "host.foo.com", + "date": "Mon, 09 Sep 2011 23:36:00 GMT", + }, + }, + ), + # GET request with duplicate out of order query key (AWS botocore tests). + # https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-vanilla-query-order-value.req + # https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-vanilla-query-order-value.sreq + ( + "us-east-1", + "2011-09-09T23:36:00Z", + { + "access_key_id": "AKIDEXAMPLE", + "secret_access_key": "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + }, + { + "method": "GET", + "url": "https://host.foo.com/?foo=b&foo=a", + "headers": {"date": "Mon, 09 Sep 2011 23:36:00 GMT"}, + }, + { + "url": "https://host.foo.com/?foo=b&foo=a", + "method": "GET", + "headers": { + "Authorization": "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/aws4_request, SignedHeaders=date;host, Signature=feb926e49e382bec75c9d7dcb2a1b6dc8aa50ca43c25d2bc51143768c0875acc", + "host": "host.foo.com", + "date": "Mon, 09 Sep 2011 23:36:00 GMT", + }, + }, + ), + # GET request with utf8 query (AWS botocore tests). + # https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-vanilla-ut8-query.req + # https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-vanilla-ut8-query.sreq + ( + "us-east-1", + "2011-09-09T23:36:00Z", + { + "access_key_id": "AKIDEXAMPLE", + "secret_access_key": "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + }, + { + "method": "GET", + "url": "https://host.foo.com/?{}=bar".format( + urllib.parse.unquote("%E1%88%B4") + ), + "headers": {"date": "Mon, 09 Sep 2011 23:36:00 GMT"}, + }, + { + "url": "https://host.foo.com/?{}=bar".format( + urllib.parse.unquote("%E1%88%B4") + ), + "method": "GET", + "headers": { + "Authorization": "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/aws4_request, SignedHeaders=date;host, Signature=6fb359e9a05394cc7074e0feb42573a2601abc0c869a953e8c5c12e4e01f1a8c", + "host": "host.foo.com", + "date": "Mon, 09 Sep 2011 23:36:00 GMT", + }, + }, + ), + # POST request with sorted headers (AWS botocore tests). + # https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/post-header-key-sort.req + # https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/post-header-key-sort.sreq + ( + "us-east-1", + "2011-09-09T23:36:00Z", + { + "access_key_id": "AKIDEXAMPLE", + "secret_access_key": "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + }, + { + "method": "POST", + "url": "https://host.foo.com/", + "headers": {"date": "Mon, 09 Sep 2011 23:36:00 GMT", "ZOO": "zoobar"}, + }, + { + "url": "https://host.foo.com/", + "method": "POST", + "headers": { + "Authorization": "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/aws4_request, SignedHeaders=date;host;zoo, Signature=b7a95a52518abbca0964a999a880429ab734f35ebbf1235bd79a5de87756dc4a", + "host": "host.foo.com", + "date": "Mon, 09 Sep 2011 23:36:00 GMT", + "ZOO": "zoobar", + }, + }, + ), + # POST request with upper case header value from AWS Python test harness. + # https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/post-header-value-case.req + # https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/post-header-value-case.sreq + ( + "us-east-1", + "2011-09-09T23:36:00Z", + { + "access_key_id": "AKIDEXAMPLE", + "secret_access_key": "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + }, + { + "method": "POST", + "url": "https://host.foo.com/", + "headers": {"date": "Mon, 09 Sep 2011 23:36:00 GMT", "zoo": "ZOOBAR"}, + }, + { + "url": "https://host.foo.com/", + "method": "POST", + "headers": { + "Authorization": "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/aws4_request, SignedHeaders=date;host;zoo, Signature=273313af9d0c265c531e11db70bbd653f3ba074c1009239e8559d3987039cad7", + "host": "host.foo.com", + "date": "Mon, 09 Sep 2011 23:36:00 GMT", + "zoo": "ZOOBAR", + }, + }, + ), + # POST request with header and no body (AWS botocore tests). + # https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-header-value-trim.req + # https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-header-value-trim.sreq + ( + "us-east-1", + "2011-09-09T23:36:00Z", + { + "access_key_id": "AKIDEXAMPLE", + "secret_access_key": "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + }, + { + "method": "POST", + "url": "https://host.foo.com/", + "headers": {"date": "Mon, 09 Sep 2011 23:36:00 GMT", "p": "phfft"}, + }, + { + "url": "https://host.foo.com/", + "method": "POST", + "headers": { + "Authorization": "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/aws4_request, SignedHeaders=date;host;p, Signature=debf546796015d6f6ded8626f5ce98597c33b47b9164cf6b17b4642036fcb592", + "host": "host.foo.com", + "date": "Mon, 09 Sep 2011 23:36:00 GMT", + "p": "phfft", + }, + }, + ), + # POST request with body and no header (AWS botocore tests). + # https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/post-x-www-form-urlencoded.req + # https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/post-x-www-form-urlencoded.sreq + ( + "us-east-1", + "2011-09-09T23:36:00Z", + { + "access_key_id": "AKIDEXAMPLE", + "secret_access_key": "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + }, + { + "method": "POST", + "url": "https://host.foo.com/", + "headers": { + "Content-Type": "application/x-www-form-urlencoded", + "date": "Mon, 09 Sep 2011 23:36:00 GMT", + }, + "data": "foo=bar", + }, + { + "url": "https://host.foo.com/", + "method": "POST", + "headers": { + "Authorization": "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/aws4_request, SignedHeaders=content-type;date;host, Signature=5a15b22cf462f047318703b92e6f4f38884e4a7ab7b1d6426ca46a8bd1c26cbc", + "host": "host.foo.com", + "Content-Type": "application/x-www-form-urlencoded", + "date": "Mon, 09 Sep 2011 23:36:00 GMT", + }, + "data": "foo=bar", + }, + ), + # POST request with querystring (AWS botocore tests). + # https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/post-vanilla-query.req + # https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/post-vanilla-query.sreq + ( + "us-east-1", + "2011-09-09T23:36:00Z", + { + "access_key_id": "AKIDEXAMPLE", + "secret_access_key": "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + }, + { + "method": "POST", + "url": "https://host.foo.com/?foo=bar", + "headers": {"date": "Mon, 09 Sep 2011 23:36:00 GMT"}, + }, + { + "url": "https://host.foo.com/?foo=bar", + "method": "POST", + "headers": { + "Authorization": "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/aws4_request, SignedHeaders=date;host, Signature=b6e3b79003ce0743a491606ba1035a804593b0efb1e20a11cba83f8c25a57a92", + "host": "host.foo.com", + "date": "Mon, 09 Sep 2011 23:36:00 GMT", + }, + }, + ), + # GET request with session token credentials. + ( + "us-east-2", + "2020-08-11T06:55:22Z", + { + "access_key_id": ACCESS_KEY_ID, + "secret_access_key": SECRET_ACCESS_KEY, + "security_token": TOKEN, + }, + { + "method": "GET", + "url": "https://ec2.us-east-2.amazonaws.com?Action=DescribeRegions&Version=2013-10-15", + }, + { + "url": "https://ec2.us-east-2.amazonaws.com?Action=DescribeRegions&Version=2013-10-15", + "method": "GET", + "headers": { + "Authorization": "AWS4-HMAC-SHA256 Credential=" + + ACCESS_KEY_ID + + "/20200811/us-east-2/ec2/aws4_request, SignedHeaders=host;x-amz-date;x-amz-security-token, Signature=41e226f997bf917ec6c9b2b14218df0874225f13bb153236c247881e614fafc9", + "host": "ec2.us-east-2.amazonaws.com", + "x-amz-date": "20200811T065522Z", + "x-amz-security-token": TOKEN, + }, + }, + ), + # POST request with session token credentials. + ( + "us-east-2", + "2020-08-11T06:55:22Z", + { + "access_key_id": ACCESS_KEY_ID, + "secret_access_key": SECRET_ACCESS_KEY, + "security_token": TOKEN, + }, + { + "method": "POST", + "url": "https://sts.us-east-2.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15", + }, + { + "url": "https://sts.us-east-2.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15", + "method": "POST", + "headers": { + "Authorization": "AWS4-HMAC-SHA256 Credential=" + + ACCESS_KEY_ID + + "/20200811/us-east-2/sts/aws4_request, SignedHeaders=host;x-amz-date;x-amz-security-token, Signature=596aa990b792d763465d73703e684ca273c45536c6d322c31be01a41d02e5b60", + "host": "sts.us-east-2.amazonaws.com", + "x-amz-date": "20200811T065522Z", + "x-amz-security-token": TOKEN, + }, + }, + ), + # POST request with computed x-amz-date and no data. + ( + "us-east-2", + "2020-08-11T06:55:22Z", + {"access_key_id": ACCESS_KEY_ID, "secret_access_key": SECRET_ACCESS_KEY}, + { + "method": "POST", + "url": "https://sts.us-east-2.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15", + }, + { + "url": "https://sts.us-east-2.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15", + "method": "POST", + "headers": { + "Authorization": "AWS4-HMAC-SHA256 Credential=" + + ACCESS_KEY_ID + + "/20200811/us-east-2/sts/aws4_request, SignedHeaders=host;x-amz-date, Signature=9e722e5b7bfa163447e2a14df118b45ebd283c5aea72019bdf921d6e7dc01a9a", + "host": "sts.us-east-2.amazonaws.com", + "x-amz-date": "20200811T065522Z", + }, + }, + ), + # POST request with session token and additional headers/data. + ( + "us-east-2", + "2020-08-11T06:55:22Z", + { + "access_key_id": ACCESS_KEY_ID, + "secret_access_key": SECRET_ACCESS_KEY, + "security_token": TOKEN, + }, + { + "method": "POST", + "url": "https://dynamodb.us-east-2.amazonaws.com/", + "headers": { + "Content-Type": "application/x-amz-json-1.0", + "x-amz-target": "DynamoDB_20120810.CreateTable", + }, + "data": REQUEST_PARAMS, + }, + { + "url": "https://dynamodb.us-east-2.amazonaws.com/", + "method": "POST", + "headers": { + "Authorization": "AWS4-HMAC-SHA256 Credential=" + + ACCESS_KEY_ID + + "/20200811/us-east-2/dynamodb/aws4_request, SignedHeaders=content-type;host;x-amz-date;x-amz-security-token;x-amz-target, Signature=eb8bce0e63654bba672d4a8acb07e72d69210c1797d56ce024dbbc31beb2a2c7", + "host": "dynamodb.us-east-2.amazonaws.com", + "x-amz-date": "20200811T065522Z", + "Content-Type": "application/x-amz-json-1.0", + "x-amz-target": "DynamoDB_20120810.CreateTable", + "x-amz-security-token": TOKEN, + }, + "data": REQUEST_PARAMS, + }, + ), +] + + +class TestRequestSigner(object): + @pytest.mark.parametrize( + "region, time, credentials, original_request, signed_request", TEST_FIXTURES + ) + @mock.patch("google.auth._helpers.utcnow") + def test_get_request_options( + self, utcnow, region, time, credentials, original_request, signed_request + ): + utcnow.return_value = datetime.datetime.strptime(time, "%Y-%m-%dT%H:%M:%SZ") + request_signer = aws.RequestSigner(region) + credentials_object = aws.AwsSecurityCredentials( + credentials.get("access_key_id"), + credentials.get("secret_access_key"), + credentials.get("security_token"), + ) + actual_signed_request = request_signer.get_request_options( + credentials_object, + original_request.get("url"), + original_request.get("method"), + original_request.get("data"), + original_request.get("headers"), + ) + + assert actual_signed_request == signed_request + + def test_get_request_options_with_missing_scheme_url(self): + request_signer = aws.RequestSigner("us-east-2") + + with pytest.raises(ValueError) as excinfo: + request_signer.get_request_options( + aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY), + "invalid", + "POST", + ) + + assert excinfo.match(r"Invalid AWS service URL") + + def test_get_request_options_with_invalid_scheme_url(self): + request_signer = aws.RequestSigner("us-east-2") + + with pytest.raises(ValueError) as excinfo: + request_signer.get_request_options( + aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY), + "http://invalid", + "POST", + ) + + assert excinfo.match(r"Invalid AWS service URL") + + def test_get_request_options_with_missing_hostname_url(self): + request_signer = aws.RequestSigner("us-east-2") + + with pytest.raises(ValueError) as excinfo: + request_signer.get_request_options( + aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY), + "https://", + "POST", + ) + + assert excinfo.match(r"Invalid AWS service URL") + + +class TestAwsSecurityCredentialsSupplier(aws.AwsSecurityCredentialsSupplier): + def __init__( + self, + security_credentials=None, + region=None, + credentials_exception=None, + region_exception=None, + expected_context=None, + ): + self._security_credentials = security_credentials + self._region = region + self._credentials_exception = credentials_exception + self._region_exception = region_exception + self._expected_context = expected_context + + def get_aws_security_credentials(self, context, request): + if self._expected_context is not None: + assert self._expected_context == context + if self._credentials_exception is not None: + raise self._credentials_exception + return self._security_credentials + + def get_aws_region(self, context, request): + if self._expected_context is not None: + assert self._expected_context == context + if self._region_exception is not None: + raise self._region_exception + return self._region + + +class TestCredentials(object): + AWS_REGION = "us-east-2" + AWS_ROLE = "gcp-aws-role" + AWS_SECURITY_CREDENTIALS_RESPONSE = { + "AccessKeyId": ACCESS_KEY_ID, + "SecretAccessKey": SECRET_ACCESS_KEY, + "Token": TOKEN, + } + AWS_IMDSV2_SESSION_TOKEN = "awsimdsv2sessiontoken" + AWS_SIGNATURE_TIME = "2020-08-11T06:55:22Z" + CREDENTIAL_SOURCE = { + "environment_id": "aws1", + "region_url": REGION_URL, + "url": SECURITY_CREDS_URL, + "regional_cred_verification_url": CRED_VERIFICATION_URL, + } + CREDENTIAL_SOURCE_IPV6 = { + "environment_id": "aws1", + "region_url": REGION_URL_IPV6, + "url": SECURITY_CREDS_URL_IPV6, + "regional_cred_verification_url": CRED_VERIFICATION_URL, + "imdsv2_session_token_url": IMDSV2_SESSION_TOKEN_URL_IPV6, + } + SUCCESS_RESPONSE = { + "access_token": "ACCESS_TOKEN", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "Bearer", + "expires_in": 3600, + "scope": " ".join(SCOPES), + } + + @classmethod + def make_serialized_aws_signed_request( + cls, + aws_security_credentials, + region_name="us-east-2", + url="https://sts.us-east-2.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15", + ): + """Utility to generate serialize AWS signed requests. + This makes it easy to assert generated subject tokens based on the + provided AWS security credentials, regions and AWS STS endpoint. + """ + request_signer = aws.RequestSigner(region_name) + signed_request = request_signer.get_request_options( + aws_security_credentials, url, "POST" + ) + reformatted_signed_request = { + "url": signed_request.get("url"), + "method": signed_request.get("method"), + "headers": [ + { + "key": "Authorization", + "value": signed_request.get("headers").get("Authorization"), + }, + {"key": "host", "value": signed_request.get("headers").get("host")}, + { + "key": "x-amz-date", + "value": signed_request.get("headers").get("x-amz-date"), + }, + ], + } + # Include security token if available. + if aws_security_credentials.session_token is not None: + reformatted_signed_request.get("headers").append( + { + "key": "x-amz-security-token", + "value": signed_request.get("headers").get("x-amz-security-token"), + } + ) + # Append x-goog-cloud-target-resource header. + reformatted_signed_request.get("headers").append( + {"key": "x-goog-cloud-target-resource", "value": AUDIENCE} + ), + return urllib.parse.quote( + json.dumps( + reformatted_signed_request, separators=(",", ":"), sort_keys=True + ) + ) + + @classmethod + def make_mock_request( + cls, + region_status=None, + region_name=None, + role_status=None, + role_name=None, + security_credentials_status=None, + security_credentials_data=None, + token_status=None, + token_data=None, + impersonation_status=None, + impersonation_data=None, + imdsv2_session_token_status=None, + imdsv2_session_token_data=None, + ): + """Utility function to generate a mock HTTP request object. + This will facilitate testing various edge cases by specify how the + various endpoints will respond while generating a Google Access token + in an AWS environment. + """ + responses = [] + + if region_status: + if imdsv2_session_token_status: + # AWS session token request + imdsv2_session_response = mock.create_autospec( + transport.Response, instance=True + ) + imdsv2_session_response.status = imdsv2_session_token_status + imdsv2_session_response.data = imdsv2_session_token_data + responses.append(imdsv2_session_response) + + # AWS region request. + region_response = mock.create_autospec(transport.Response, instance=True) + region_response.status = region_status + if region_name: + region_response.data = "{}b".format(region_name).encode("utf-8") + responses.append(region_response) + + if imdsv2_session_token_status: + # AWS session token request + imdsv2_session_response = mock.create_autospec( + transport.Response, instance=True + ) + imdsv2_session_response.status = imdsv2_session_token_status + imdsv2_session_response.data = imdsv2_session_token_data + responses.append(imdsv2_session_response) + + if role_status: + # AWS role name request. + role_response = mock.create_autospec(transport.Response, instance=True) + role_response.status = role_status + if role_name: + role_response.data = role_name.encode("utf-8") + responses.append(role_response) + + if security_credentials_status: + # AWS security credentials request. + security_credentials_response = mock.create_autospec( + transport.Response, instance=True + ) + security_credentials_response.status = security_credentials_status + if security_credentials_data: + security_credentials_response.data = json.dumps( + security_credentials_data + ).encode("utf-8") + responses.append(security_credentials_response) + + if token_status: + # GCP token exchange request. + token_response = mock.create_autospec(transport.Response, instance=True) + token_response.status = token_status + token_response.data = json.dumps(token_data).encode("utf-8") + responses.append(token_response) + + if impersonation_status: + # Service account impersonation request. + impersonation_response = mock.create_autospec( + transport.Response, instance=True + ) + impersonation_response.status = impersonation_status + impersonation_response.data = json.dumps(impersonation_data).encode("utf-8") + responses.append(impersonation_response) + + request = mock.create_autospec(transport.Request) + request.side_effect = responses + + return request + + @classmethod + def make_credentials( + cls, + credential_source=None, + aws_security_credentials_supplier=None, + token_url=TOKEN_URL, + token_info_url=TOKEN_INFO_URL, + client_id=None, + client_secret=None, + quota_project_id=None, + scopes=None, + default_scopes=None, + service_account_impersonation_url=None, + ): + return aws.Credentials( + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=token_url, + token_info_url=token_info_url, + service_account_impersonation_url=service_account_impersonation_url, + credential_source=credential_source, + aws_security_credentials_supplier=aws_security_credentials_supplier, + client_id=client_id, + client_secret=client_secret, + quota_project_id=quota_project_id, + scopes=scopes, + default_scopes=default_scopes, + ) + + @classmethod + def assert_aws_metadata_request_kwargs( + cls, request_kwargs, url, headers=None, method="GET" + ): + assert request_kwargs["url"] == url + # All used AWS metadata server endpoints use GET HTTP method. + assert request_kwargs["method"] == method + if headers: + assert request_kwargs["headers"] == headers + else: + assert "headers" not in request_kwargs or request_kwargs["headers"] is None + # None of the endpoints used require any data in request. + assert "body" not in request_kwargs + + @classmethod + def assert_token_request_kwargs( + cls, request_kwargs, headers, request_data, token_url=TOKEN_URL + ): + assert request_kwargs["url"] == token_url + assert request_kwargs["method"] == "POST" + assert request_kwargs["headers"] == headers + assert request_kwargs["body"] is not None + body_tuples = urllib.parse.parse_qsl(request_kwargs["body"]) + assert len(body_tuples) == len(request_data.keys()) + for k, v in body_tuples: + assert v.decode("utf-8") == request_data[k.decode("utf-8")] + + @classmethod + def assert_impersonation_request_kwargs( + cls, + request_kwargs, + headers, + request_data, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + ): + assert request_kwargs["url"] == service_account_impersonation_url + assert request_kwargs["method"] == "POST" + assert request_kwargs["headers"] == headers + assert request_kwargs["body"] is not None + body_json = json.loads(request_kwargs["body"].decode("utf-8")) + assert body_json == request_data + + @mock.patch.object(aws.Credentials, "__init__", return_value=None) + def test_from_info_full_options(self, mock_init): + credentials = aws.Credentials.from_info( + { + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "token_info_url": TOKEN_INFO_URL, + "service_account_impersonation_url": SERVICE_ACCOUNT_IMPERSONATION_URL, + "service_account_impersonation": {"token_lifetime_seconds": 2800}, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "quota_project_id": QUOTA_PROJECT_ID, + "credential_source": self.CREDENTIAL_SOURCE, + } + ) + + # Confirm aws.Credentials instance initialized with the expected parameters. + assert isinstance(credentials, aws.Credentials) + mock_init.assert_called_once_with( + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + token_info_url=TOKEN_INFO_URL, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + service_account_impersonation_options={"token_lifetime_seconds": 2800}, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + credential_source=self.CREDENTIAL_SOURCE, + aws_security_credentials_supplier=None, + quota_project_id=QUOTA_PROJECT_ID, + workforce_pool_user_project=None, + universe_domain=DEFAULT_UNIVERSE_DOMAIN, + trust_boundary=None, + ) + + @mock.patch.object(aws.Credentials, "__init__", return_value=None) + def test_from_info_required_options_only(self, mock_init): + credentials = aws.Credentials.from_info( + { + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "credential_source": self.CREDENTIAL_SOURCE, + } + ) + + # Confirm aws.Credentials instance initialized with the expected parameters. + assert isinstance(credentials, aws.Credentials) + mock_init.assert_called_once_with( + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + token_info_url=None, + service_account_impersonation_url=None, + service_account_impersonation_options={}, + client_id=None, + client_secret=None, + credential_source=self.CREDENTIAL_SOURCE, + aws_security_credentials_supplier=None, + quota_project_id=None, + workforce_pool_user_project=None, + universe_domain=DEFAULT_UNIVERSE_DOMAIN, + trust_boundary=None, + ) + + @mock.patch.object(aws.Credentials, "__init__", return_value=None) + def test_from_info_supplier(self, mock_init): + supplier = TestAwsSecurityCredentialsSupplier() + + credentials = aws.Credentials.from_info( + { + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "aws_security_credentials_supplier": supplier, + } + ) + + # Confirm aws.Credentials instance initialized with the expected parameters. + assert isinstance(credentials, aws.Credentials) + mock_init.assert_called_once_with( + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + token_info_url=None, + service_account_impersonation_url=None, + service_account_impersonation_options={}, + client_id=None, + client_secret=None, + credential_source=None, + aws_security_credentials_supplier=supplier, + quota_project_id=None, + workforce_pool_user_project=None, + universe_domain=DEFAULT_UNIVERSE_DOMAIN, + trust_boundary=None, + ) + + @mock.patch.object(aws.Credentials, "__init__", return_value=None) + def test_from_file_full_options(self, mock_init, tmpdir): + info = { + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "token_info_url": TOKEN_INFO_URL, + "service_account_impersonation_url": SERVICE_ACCOUNT_IMPERSONATION_URL, + "service_account_impersonation": {"token_lifetime_seconds": 2800}, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "quota_project_id": QUOTA_PROJECT_ID, + "credential_source": self.CREDENTIAL_SOURCE, + "universe_domain": DEFAULT_UNIVERSE_DOMAIN, + } + config_file = tmpdir.join("config.json") + config_file.write(json.dumps(info)) + credentials = aws.Credentials.from_file(str(config_file)) + + # Confirm aws.Credentials instance initialized with the expected parameters. + assert isinstance(credentials, aws.Credentials) + mock_init.assert_called_once_with( + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + token_info_url=TOKEN_INFO_URL, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + service_account_impersonation_options={"token_lifetime_seconds": 2800}, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + credential_source=self.CREDENTIAL_SOURCE, + aws_security_credentials_supplier=None, + quota_project_id=QUOTA_PROJECT_ID, + workforce_pool_user_project=None, + universe_domain=DEFAULT_UNIVERSE_DOMAIN, + trust_boundary=None, + ) + + @mock.patch.object(aws.Credentials, "__init__", return_value=None) + def test_from_file_required_options_only(self, mock_init, tmpdir): + info = { + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "credential_source": self.CREDENTIAL_SOURCE, + } + config_file = tmpdir.join("config.json") + config_file.write(json.dumps(info)) + credentials = aws.Credentials.from_file(str(config_file)) + + # Confirm aws.Credentials instance initialized with the expected parameters. + assert isinstance(credentials, aws.Credentials) + mock_init.assert_called_once_with( + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + token_info_url=None, + service_account_impersonation_url=None, + service_account_impersonation_options={}, + client_id=None, + client_secret=None, + credential_source=self.CREDENTIAL_SOURCE, + aws_security_credentials_supplier=None, + quota_project_id=None, + workforce_pool_user_project=None, + universe_domain=DEFAULT_UNIVERSE_DOMAIN, + trust_boundary=None, + ) + + def test_constructor_invalid_credential_source(self): + # Provide invalid credential source. + credential_source = {"unsupported": "value"} + + with pytest.raises(ValueError) as excinfo: + self.make_credentials(credential_source=credential_source) + + assert excinfo.match(r"No valid AWS 'credential_source' provided") + + def test_constructor_invalid_credential_source_and_supplier(self): + # Provide both a credential source and supplier. + with pytest.raises(ValueError) as excinfo: + self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE, + aws_security_credentials_supplier="test", + ) + + assert excinfo.match( + r"AWS credential cannot have both a credential source and an AWS security credentials supplier." + ) + + def test_constructor_invalid_no_credential_source_or_supplier(self): + # Provide no credential source or supplier. + with pytest.raises(ValueError) as excinfo: + self.make_credentials() + + assert excinfo.match( + r"A valid credential source or AWS security credentials supplier must be provided." + ) + + def test_constructor_invalid_environment_id(self): + # Provide invalid environment_id. + credential_source = self.CREDENTIAL_SOURCE.copy() + credential_source["environment_id"] = "azure1" + + with pytest.raises(ValueError) as excinfo: + self.make_credentials(credential_source=credential_source) + + assert excinfo.match(r"No valid AWS 'credential_source' provided") + + def test_constructor_missing_cred_verification_url(self): + # regional_cred_verification_url is a required field. + credential_source = self.CREDENTIAL_SOURCE.copy() + credential_source.pop("regional_cred_verification_url") + + with pytest.raises(ValueError) as excinfo: + self.make_credentials(credential_source=credential_source) + + assert excinfo.match(r"No valid AWS 'credential_source' provided") + + def test_constructor_invalid_environment_id_version(self): + # Provide an unsupported version. + credential_source = self.CREDENTIAL_SOURCE.copy() + credential_source["environment_id"] = "aws3" + + with pytest.raises(ValueError) as excinfo: + self.make_credentials(credential_source=credential_source) + + assert excinfo.match(r"aws version '3' is not supported in the current build.") + + def test_info(self): + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE.copy() + ) + + assert credentials.info == { + "type": "external_account", + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "token_info_url": TOKEN_INFO_URL, + "credential_source": self.CREDENTIAL_SOURCE, + "universe_domain": DEFAULT_UNIVERSE_DOMAIN, + } + + def test_token_info_url(self): + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE.copy() + ) + + assert credentials.token_info_url == TOKEN_INFO_URL + + def test_token_info_url_custom(self): + for url in VALID_TOKEN_URLS: + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE.copy(), + token_info_url=(url + "/introspect"), + ) + + assert credentials.token_info_url == (url + "/introspect") + + def test_token_info_url_negative(self): + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE.copy(), token_info_url=None + ) + + assert not credentials.token_info_url + + def test_token_url_custom(self): + for url in VALID_TOKEN_URLS: + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE.copy(), + token_url=(url + "/token"), + ) + + assert credentials._token_url == (url + "/token") + + def test_service_account_impersonation_url_custom(self): + for url in VALID_SERVICE_ACCOUNT_IMPERSONATION_URLS: + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE.copy(), + service_account_impersonation_url=( + url + SERVICE_ACCOUNT_IMPERSONATION_URL_ROUTE + ), + ) + + assert credentials._service_account_impersonation_url == ( + url + SERVICE_ACCOUNT_IMPERSONATION_URL_ROUTE + ) + + def test_info_with_default_token_url(self): + credentials = aws.Credentials( + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + credential_source=self.CREDENTIAL_SOURCE.copy(), + ) + + assert credentials.info == { + "type": "external_account", + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "credential_source": self.CREDENTIAL_SOURCE.copy(), + "universe_domain": DEFAULT_UNIVERSE_DOMAIN, + } + + def test_info_with_default_token_url_with_universe_domain(self): + credentials = aws.Credentials( + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + credential_source=self.CREDENTIAL_SOURCE.copy(), + universe_domain="testdomain.org", + ) + + assert credentials.info == { + "type": "external_account", + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": "https://sts.testdomain.org/v1/token", + "credential_source": self.CREDENTIAL_SOURCE.copy(), + "universe_domain": "testdomain.org", + } + + def test_retrieve_subject_token_missing_region_url(self): + # When AWS_REGION envvar is not available, region_url is required for + # determining the current AWS region. + credential_source = self.CREDENTIAL_SOURCE.copy() + credential_source.pop("region_url") + credentials = self.make_credentials(credential_source=credential_source) + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.retrieve_subject_token(None) + + assert excinfo.match(r"Unable to determine AWS region") + + @mock.patch("google.auth._helpers.utcnow") + def test_retrieve_subject_token_success_temp_creds_no_environment_vars( + self, utcnow + ): + utcnow.return_value = datetime.datetime.strptime( + self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ" + ) + request = self.make_mock_request( + region_status=http_client.OK, + region_name=self.AWS_REGION, + role_status=http_client.OK, + role_name=self.AWS_ROLE, + security_credentials_status=http_client.OK, + security_credentials_data=self.AWS_SECURITY_CREDENTIALS_RESPONSE, + ) + credentials = self.make_credentials(credential_source=self.CREDENTIAL_SOURCE) + + subject_token = credentials.retrieve_subject_token(request) + + assert subject_token == self.make_serialized_aws_signed_request( + aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN) + ) + # Assert region request. + self.assert_aws_metadata_request_kwargs( + request.call_args_list[0][1], REGION_URL + ) + # Assert role request. + self.assert_aws_metadata_request_kwargs( + request.call_args_list[1][1], SECURITY_CREDS_URL + ) + # Assert security credentials request. + self.assert_aws_metadata_request_kwargs( + request.call_args_list[2][1], + "{}/{}".format(SECURITY_CREDS_URL, self.AWS_ROLE), + {"Content-Type": "application/json"}, + ) + + # Retrieve subject_token again. Region should not be queried again. + new_request = self.make_mock_request( + role_status=http_client.OK, + role_name=self.AWS_ROLE, + security_credentials_status=http_client.OK, + security_credentials_data=self.AWS_SECURITY_CREDENTIALS_RESPONSE, + ) + + credentials.retrieve_subject_token(new_request) + + # Only 3 requests should be sent as the region is cached. + assert len(new_request.call_args_list) == 2 + # Assert role request. + self.assert_aws_metadata_request_kwargs( + new_request.call_args_list[0][1], SECURITY_CREDS_URL + ) + # Assert security credentials request. + self.assert_aws_metadata_request_kwargs( + new_request.call_args_list[1][1], + "{}/{}".format(SECURITY_CREDS_URL, self.AWS_ROLE), + {"Content-Type": "application/json"}, + ) + + @mock.patch("google.auth._helpers.utcnow") + @mock.patch.dict(os.environ, {}) + def test_retrieve_subject_token_success_temp_creds_no_environment_vars_idmsv2( + self, utcnow + ): + utcnow.return_value = datetime.datetime.strptime( + self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ" + ) + request = self.make_mock_request( + region_status=http_client.OK, + region_name=self.AWS_REGION, + role_status=http_client.OK, + role_name=self.AWS_ROLE, + security_credentials_status=http_client.OK, + security_credentials_data=self.AWS_SECURITY_CREDENTIALS_RESPONSE, + imdsv2_session_token_status=http_client.OK, + imdsv2_session_token_data=self.AWS_IMDSV2_SESSION_TOKEN, + ) + credential_source_token_url = self.CREDENTIAL_SOURCE.copy() + credential_source_token_url[ + "imdsv2_session_token_url" + ] = IMDSV2_SESSION_TOKEN_URL + credentials = self.make_credentials( + credential_source=credential_source_token_url + ) + + subject_token = credentials.retrieve_subject_token(request) + + assert subject_token == self.make_serialized_aws_signed_request( + aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN) + ) + # Assert session token request + self.assert_aws_metadata_request_kwargs( + request.call_args_list[0][1], + IMDSV2_SESSION_TOKEN_URL, + {"X-aws-ec2-metadata-token-ttl-seconds": "300"}, + "PUT", + ) + # Assert region request. + self.assert_aws_metadata_request_kwargs( + request.call_args_list[1][1], + REGION_URL, + {"X-aws-ec2-metadata-token": self.AWS_IMDSV2_SESSION_TOKEN}, + ) + # Assert session token request + self.assert_aws_metadata_request_kwargs( + request.call_args_list[2][1], + IMDSV2_SESSION_TOKEN_URL, + {"X-aws-ec2-metadata-token-ttl-seconds": "300"}, + "PUT", + ) + # Assert role request. + self.assert_aws_metadata_request_kwargs( + request.call_args_list[3][1], + SECURITY_CREDS_URL, + {"X-aws-ec2-metadata-token": self.AWS_IMDSV2_SESSION_TOKEN}, + ) + # Assert security credentials request. + self.assert_aws_metadata_request_kwargs( + request.call_args_list[4][1], + "{}/{}".format(SECURITY_CREDS_URL, self.AWS_ROLE), + { + "Content-Type": "application/json", + "X-aws-ec2-metadata-token": self.AWS_IMDSV2_SESSION_TOKEN, + }, + ) + + # Retrieve subject_token again. Region should not be queried again. + new_request = self.make_mock_request( + role_status=http_client.OK, + role_name=self.AWS_ROLE, + security_credentials_status=http_client.OK, + security_credentials_data=self.AWS_SECURITY_CREDENTIALS_RESPONSE, + imdsv2_session_token_status=http_client.OK, + imdsv2_session_token_data=self.AWS_IMDSV2_SESSION_TOKEN, + ) + + credentials.retrieve_subject_token(new_request) + + # Only 3 requests should be sent as the region is cached. + assert len(new_request.call_args_list) == 3 + # Assert session token request. + self.assert_aws_metadata_request_kwargs( + request.call_args_list[0][1], + IMDSV2_SESSION_TOKEN_URL, + {"X-aws-ec2-metadata-token-ttl-seconds": "300"}, + "PUT", + ) + # Assert role request. + self.assert_aws_metadata_request_kwargs( + new_request.call_args_list[1][1], + SECURITY_CREDS_URL, + {"X-aws-ec2-metadata-token": self.AWS_IMDSV2_SESSION_TOKEN}, + ) + # Assert security credentials request. + self.assert_aws_metadata_request_kwargs( + new_request.call_args_list[2][1], + "{}/{}".format(SECURITY_CREDS_URL, self.AWS_ROLE), + { + "Content-Type": "application/json", + "X-aws-ec2-metadata-token": self.AWS_IMDSV2_SESSION_TOKEN, + }, + ) + + @mock.patch("google.auth._helpers.utcnow") + @mock.patch.dict( + os.environ, + { + environment_vars.AWS_REGION: AWS_REGION, + environment_vars.AWS_ACCESS_KEY_ID: ACCESS_KEY_ID, + }, + ) + def test_retrieve_subject_token_success_temp_creds_environment_vars_missing_secret_access_key_idmsv2( + self, utcnow + ): + utcnow.return_value = datetime.datetime.strptime( + self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ" + ) + request = self.make_mock_request( + role_status=http_client.OK, + role_name=self.AWS_ROLE, + security_credentials_status=http_client.OK, + security_credentials_data=self.AWS_SECURITY_CREDENTIALS_RESPONSE, + imdsv2_session_token_status=http_client.OK, + imdsv2_session_token_data=self.AWS_IMDSV2_SESSION_TOKEN, + ) + credential_source_token_url = self.CREDENTIAL_SOURCE.copy() + credential_source_token_url[ + "imdsv2_session_token_url" + ] = IMDSV2_SESSION_TOKEN_URL + credentials = self.make_credentials( + credential_source=credential_source_token_url + ) + + subject_token = credentials.retrieve_subject_token(request) + assert subject_token == self.make_serialized_aws_signed_request( + aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN) + ) + # Assert session token request. + self.assert_aws_metadata_request_kwargs( + request.call_args_list[0][1], + IMDSV2_SESSION_TOKEN_URL, + {"X-aws-ec2-metadata-token-ttl-seconds": "300"}, + "PUT", + ) + # Assert role request. + self.assert_aws_metadata_request_kwargs( + request.call_args_list[1][1], + SECURITY_CREDS_URL, + {"X-aws-ec2-metadata-token": self.AWS_IMDSV2_SESSION_TOKEN}, + ) + # Assert security credentials request. + self.assert_aws_metadata_request_kwargs( + request.call_args_list[2][1], + "{}/{}".format(SECURITY_CREDS_URL, self.AWS_ROLE), + { + "Content-Type": "application/json", + "X-aws-ec2-metadata-token": self.AWS_IMDSV2_SESSION_TOKEN, + }, + ) + + @mock.patch("google.auth._helpers.utcnow") + @mock.patch.dict( + os.environ, + { + environment_vars.AWS_REGION: AWS_REGION, + environment_vars.AWS_SECRET_ACCESS_KEY: SECRET_ACCESS_KEY, + }, + ) + def test_retrieve_subject_token_success_temp_creds_environment_vars_missing_access_key_id_idmsv2( + self, utcnow + ): + utcnow.return_value = datetime.datetime.strptime( + self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ" + ) + request = self.make_mock_request( + role_status=http_client.OK, + role_name=self.AWS_ROLE, + security_credentials_status=http_client.OK, + security_credentials_data=self.AWS_SECURITY_CREDENTIALS_RESPONSE, + imdsv2_session_token_status=http_client.OK, + imdsv2_session_token_data=self.AWS_IMDSV2_SESSION_TOKEN, + ) + credential_source_token_url = self.CREDENTIAL_SOURCE.copy() + credential_source_token_url[ + "imdsv2_session_token_url" + ] = IMDSV2_SESSION_TOKEN_URL + credentials = self.make_credentials( + credential_source=credential_source_token_url + ) + + subject_token = credentials.retrieve_subject_token(request) + assert subject_token == self.make_serialized_aws_signed_request( + aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN) + ) + # Assert session token request. + self.assert_aws_metadata_request_kwargs( + request.call_args_list[0][1], + IMDSV2_SESSION_TOKEN_URL, + {"X-aws-ec2-metadata-token-ttl-seconds": "300"}, + "PUT", + ) + # Assert role request. + self.assert_aws_metadata_request_kwargs( + request.call_args_list[1][1], + SECURITY_CREDS_URL, + {"X-aws-ec2-metadata-token": self.AWS_IMDSV2_SESSION_TOKEN}, + ) + # Assert security credentials request. + self.assert_aws_metadata_request_kwargs( + request.call_args_list[2][1], + "{}/{}".format(SECURITY_CREDS_URL, self.AWS_ROLE), + { + "Content-Type": "application/json", + "X-aws-ec2-metadata-token": self.AWS_IMDSV2_SESSION_TOKEN, + }, + ) + + @mock.patch("google.auth._helpers.utcnow") + @mock.patch.dict(os.environ, {environment_vars.AWS_REGION: AWS_REGION}) + def test_retrieve_subject_token_success_temp_creds_environment_vars_missing_creds_idmsv2( + self, utcnow + ): + utcnow.return_value = datetime.datetime.strptime( + self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ" + ) + request = self.make_mock_request( + role_status=http_client.OK, + role_name=self.AWS_ROLE, + security_credentials_status=http_client.OK, + security_credentials_data=self.AWS_SECURITY_CREDENTIALS_RESPONSE, + imdsv2_session_token_status=http_client.OK, + imdsv2_session_token_data=self.AWS_IMDSV2_SESSION_TOKEN, + ) + credential_source_token_url = self.CREDENTIAL_SOURCE.copy() + credential_source_token_url[ + "imdsv2_session_token_url" + ] = IMDSV2_SESSION_TOKEN_URL + credentials = self.make_credentials( + credential_source=credential_source_token_url + ) + + subject_token = credentials.retrieve_subject_token(request) + assert subject_token == self.make_serialized_aws_signed_request( + aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN) + ) + # Assert session token request. + self.assert_aws_metadata_request_kwargs( + request.call_args_list[0][1], + IMDSV2_SESSION_TOKEN_URL, + {"X-aws-ec2-metadata-token-ttl-seconds": "300"}, + "PUT", + ) + # Assert role request. + self.assert_aws_metadata_request_kwargs( + request.call_args_list[1][1], + SECURITY_CREDS_URL, + {"X-aws-ec2-metadata-token": self.AWS_IMDSV2_SESSION_TOKEN}, + ) + # Assert security credentials request. + self.assert_aws_metadata_request_kwargs( + request.call_args_list[2][1], + "{}/{}".format(SECURITY_CREDS_URL, self.AWS_ROLE), + { + "Content-Type": "application/json", + "X-aws-ec2-metadata-token": self.AWS_IMDSV2_SESSION_TOKEN, + }, + ) + + @mock.patch("google.auth._helpers.utcnow") + @mock.patch.dict( + os.environ, + { + environment_vars.AWS_REGION: AWS_REGION, + environment_vars.AWS_ACCESS_KEY_ID: ACCESS_KEY_ID, + environment_vars.AWS_SECRET_ACCESS_KEY: SECRET_ACCESS_KEY, + }, + ) + def test_retrieve_subject_token_success_temp_creds_idmsv2(self, utcnow): + utcnow.return_value = datetime.datetime.strptime( + self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ" + ) + request = self.make_mock_request( + role_status=http_client.OK, role_name=self.AWS_ROLE + ) + credential_source_token_url = self.CREDENTIAL_SOURCE.copy() + credential_source_token_url[ + "imdsv2_session_token_url" + ] = IMDSV2_SESSION_TOKEN_URL + credentials = self.make_credentials( + credential_source=credential_source_token_url + ) + + credentials.retrieve_subject_token(request) + assert not request.called + + @mock.patch("google.auth._helpers.utcnow") + def test_retrieve_subject_token_success_ipv6(self, utcnow): + utcnow.return_value = datetime.datetime.strptime( + self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ" + ) + request = self.make_mock_request( + region_status=http_client.OK, + region_name=self.AWS_REGION, + role_status=http_client.OK, + role_name=self.AWS_ROLE, + security_credentials_status=http_client.OK, + security_credentials_data=self.AWS_SECURITY_CREDENTIALS_RESPONSE, + imdsv2_session_token_status=http_client.OK, + imdsv2_session_token_data=self.AWS_IMDSV2_SESSION_TOKEN, + ) + credential_source_token_url = self.CREDENTIAL_SOURCE_IPV6.copy() + credentials = self.make_credentials( + credential_source=credential_source_token_url + ) + + subject_token = credentials.retrieve_subject_token(request) + + assert subject_token == self.make_serialized_aws_signed_request( + aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN) + ) + # Assert session token request. + self.assert_aws_metadata_request_kwargs( + request.call_args_list[0][1], + IMDSV2_SESSION_TOKEN_URL_IPV6, + {"X-aws-ec2-metadata-token-ttl-seconds": "300"}, + "PUT", + ) + # Assert region request. + self.assert_aws_metadata_request_kwargs( + request.call_args_list[1][1], + REGION_URL_IPV6, + {"X-aws-ec2-metadata-token": self.AWS_IMDSV2_SESSION_TOKEN}, + ) + # Assert session token request. + self.assert_aws_metadata_request_kwargs( + request.call_args_list[2][1], + IMDSV2_SESSION_TOKEN_URL_IPV6, + {"X-aws-ec2-metadata-token-ttl-seconds": "300"}, + "PUT", + ) + # Assert role request. + self.assert_aws_metadata_request_kwargs( + request.call_args_list[3][1], + SECURITY_CREDS_URL_IPV6, + {"X-aws-ec2-metadata-token": self.AWS_IMDSV2_SESSION_TOKEN}, + ) + # Assert security credentials request. + self.assert_aws_metadata_request_kwargs( + request.call_args_list[4][1], + "{}/{}".format(SECURITY_CREDS_URL_IPV6, self.AWS_ROLE), + { + "Content-Type": "application/json", + "X-aws-ec2-metadata-token": self.AWS_IMDSV2_SESSION_TOKEN, + }, + ) + + @mock.patch("google.auth._helpers.utcnow") + def test_retrieve_subject_token_session_error_idmsv2(self, utcnow): + utcnow.return_value = datetime.datetime.strptime( + self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ" + ) + request = self.make_mock_request( + imdsv2_session_token_status=http_client.UNAUTHORIZED, + imdsv2_session_token_data="unauthorized", + ) + credential_source_token_url = self.CREDENTIAL_SOURCE.copy() + credential_source_token_url[ + "imdsv2_session_token_url" + ] = IMDSV2_SESSION_TOKEN_URL + credentials = self.make_credentials( + credential_source=credential_source_token_url + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.retrieve_subject_token(request) + + assert excinfo.match(r"Unable to retrieve AWS Session Token") + + # Assert session token request + self.assert_aws_metadata_request_kwargs( + request.call_args_list[0][1], + IMDSV2_SESSION_TOKEN_URL, + {"X-aws-ec2-metadata-token-ttl-seconds": "300"}, + "PUT", + ) + + @mock.patch("google.auth._helpers.utcnow") + def test_retrieve_subject_token_success_permanent_creds_no_environment_vars( + self, utcnow + ): + # Simualte a permanent credential without a session token is + # returned by the security-credentials endpoint. + security_creds_response = self.AWS_SECURITY_CREDENTIALS_RESPONSE.copy() + security_creds_response.pop("Token") + utcnow.return_value = datetime.datetime.strptime( + self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ" + ) + request = self.make_mock_request( + region_status=http_client.OK, + region_name=self.AWS_REGION, + role_status=http_client.OK, + role_name=self.AWS_ROLE, + security_credentials_status=http_client.OK, + security_credentials_data=security_creds_response, + ) + credentials = self.make_credentials(credential_source=self.CREDENTIAL_SOURCE) + + subject_token = credentials.retrieve_subject_token(request) + + assert subject_token == self.make_serialized_aws_signed_request( + aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY) + ) + + @mock.patch("google.auth._helpers.utcnow") + def test_retrieve_subject_token_success_environment_vars(self, utcnow, monkeypatch): + monkeypatch.setenv(environment_vars.AWS_ACCESS_KEY_ID, ACCESS_KEY_ID) + monkeypatch.setenv(environment_vars.AWS_SECRET_ACCESS_KEY, SECRET_ACCESS_KEY) + monkeypatch.setenv(environment_vars.AWS_SESSION_TOKEN, TOKEN) + monkeypatch.setenv(environment_vars.AWS_REGION, self.AWS_REGION) + utcnow.return_value = datetime.datetime.strptime( + self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ" + ) + credentials = self.make_credentials(credential_source=self.CREDENTIAL_SOURCE) + + subject_token = credentials.retrieve_subject_token(None) + + assert subject_token == self.make_serialized_aws_signed_request( + aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN) + ) + + @mock.patch("google.auth._helpers.utcnow") + def test_retrieve_subject_token_success_environment_vars_with_default_region( + self, utcnow, monkeypatch + ): + monkeypatch.setenv(environment_vars.AWS_ACCESS_KEY_ID, ACCESS_KEY_ID) + monkeypatch.setenv(environment_vars.AWS_SECRET_ACCESS_KEY, SECRET_ACCESS_KEY) + monkeypatch.setenv(environment_vars.AWS_SESSION_TOKEN, TOKEN) + monkeypatch.setenv(environment_vars.AWS_DEFAULT_REGION, self.AWS_REGION) + utcnow.return_value = datetime.datetime.strptime( + self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ" + ) + credentials = self.make_credentials(credential_source=self.CREDENTIAL_SOURCE) + + subject_token = credentials.retrieve_subject_token(None) + + assert subject_token == self.make_serialized_aws_signed_request( + aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN) + ) + + @mock.patch("google.auth._helpers.utcnow") + def test_retrieve_subject_token_success_environment_vars_with_both_regions_set( + self, utcnow, monkeypatch + ): + monkeypatch.setenv(environment_vars.AWS_ACCESS_KEY_ID, ACCESS_KEY_ID) + monkeypatch.setenv(environment_vars.AWS_SECRET_ACCESS_KEY, SECRET_ACCESS_KEY) + monkeypatch.setenv(environment_vars.AWS_SESSION_TOKEN, TOKEN) + monkeypatch.setenv(environment_vars.AWS_DEFAULT_REGION, "Malformed AWS Region") + # This test makes sure that the AWS_REGION gets used over AWS_DEFAULT_REGION, + # So, AWS_DEFAULT_REGION is set to something that would cause the test to fail, + # And AWS_REGION is set to the a valid value, and it should succeed + monkeypatch.setenv(environment_vars.AWS_REGION, self.AWS_REGION) + utcnow.return_value = datetime.datetime.strptime( + self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ" + ) + credentials = self.make_credentials(credential_source=self.CREDENTIAL_SOURCE) + + subject_token = credentials.retrieve_subject_token(None) + + assert subject_token == self.make_serialized_aws_signed_request( + aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN) + ) + + @mock.patch("google.auth._helpers.utcnow") + def test_retrieve_subject_token_success_environment_vars_no_session_token( + self, utcnow, monkeypatch + ): + monkeypatch.setenv(environment_vars.AWS_ACCESS_KEY_ID, ACCESS_KEY_ID) + monkeypatch.setenv(environment_vars.AWS_SECRET_ACCESS_KEY, SECRET_ACCESS_KEY) + monkeypatch.setenv(environment_vars.AWS_REGION, self.AWS_REGION) + utcnow.return_value = datetime.datetime.strptime( + self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ" + ) + credentials = self.make_credentials(credential_source=self.CREDENTIAL_SOURCE) + + subject_token = credentials.retrieve_subject_token(None) + + assert subject_token == self.make_serialized_aws_signed_request( + aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY) + ) + + @mock.patch("google.auth._helpers.utcnow") + def test_retrieve_subject_token_success_environment_vars_except_region( + self, utcnow, monkeypatch + ): + monkeypatch.setenv(environment_vars.AWS_ACCESS_KEY_ID, ACCESS_KEY_ID) + monkeypatch.setenv(environment_vars.AWS_SECRET_ACCESS_KEY, SECRET_ACCESS_KEY) + monkeypatch.setenv(environment_vars.AWS_SESSION_TOKEN, TOKEN) + utcnow.return_value = datetime.datetime.strptime( + self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ" + ) + # Region will be queried since it is not found in envvars. + request = self.make_mock_request( + region_status=http_client.OK, region_name=self.AWS_REGION + ) + credentials = self.make_credentials(credential_source=self.CREDENTIAL_SOURCE) + + subject_token = credentials.retrieve_subject_token(request) + + assert subject_token == self.make_serialized_aws_signed_request( + aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN) + ) + + def test_retrieve_subject_token_error_determining_aws_region(self): + # Simulate error in retrieving the AWS region. + request = self.make_mock_request(region_status=http_client.BAD_REQUEST) + credentials = self.make_credentials(credential_source=self.CREDENTIAL_SOURCE) + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.retrieve_subject_token(request) + + assert excinfo.match(r"Unable to retrieve AWS region") + + def test_retrieve_subject_token_error_determining_aws_role(self): + # Simulate error in retrieving the AWS role name. + request = self.make_mock_request( + region_status=http_client.OK, + region_name=self.AWS_REGION, + role_status=http_client.BAD_REQUEST, + ) + credentials = self.make_credentials(credential_source=self.CREDENTIAL_SOURCE) + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.retrieve_subject_token(request) + + assert excinfo.match(r"Unable to retrieve AWS role name") + + def test_retrieve_subject_token_error_determining_security_creds_url(self): + # Simulate the security-credentials url is missing. This is needed for + # determining the AWS security credentials when not found in envvars. + credential_source = self.CREDENTIAL_SOURCE.copy() + credential_source.pop("url") + request = self.make_mock_request( + region_status=http_client.OK, region_name=self.AWS_REGION + ) + credentials = self.make_credentials(credential_source=credential_source) + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.retrieve_subject_token(request) + + assert excinfo.match( + r"Unable to determine the AWS metadata server security credentials endpoint" + ) + + def test_retrieve_subject_token_error_determining_aws_security_creds(self): + # Simulate error in retrieving the AWS security credentials. + request = self.make_mock_request( + region_status=http_client.OK, + region_name=self.AWS_REGION, + role_status=http_client.OK, + role_name=self.AWS_ROLE, + security_credentials_status=http_client.BAD_REQUEST, + ) + credentials = self.make_credentials(credential_source=self.CREDENTIAL_SOURCE) + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.retrieve_subject_token(request) + + assert excinfo.match(r"Unable to retrieve AWS security credentials") + + @mock.patch( + "google.auth.metrics.python_and_auth_lib_version", + return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, + ) + @mock.patch("google.auth._helpers.utcnow") + def test_refresh_success_without_impersonation_ignore_default_scopes( + self, utcnow, mock_auth_lib_value + ): + utcnow.return_value = datetime.datetime.strptime( + self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ" + ) + expected_subject_token = self.make_serialized_aws_signed_request( + aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN) + ) + token_headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Basic " + BASIC_AUTH_ENCODING, + "x-goog-api-client": "gl-python/3.7 auth/1.1 google-byoid-sdk sa-impersonation/false config-lifetime/false source/aws", + } + token_request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": AUDIENCE, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "scope": " ".join(SCOPES), + "subject_token": expected_subject_token, + "subject_token_type": SUBJECT_TOKEN_TYPE, + } + request = self.make_mock_request( + region_status=http_client.OK, + region_name=self.AWS_REGION, + role_status=http_client.OK, + role_name=self.AWS_ROLE, + security_credentials_status=http_client.OK, + security_credentials_data=self.AWS_SECURITY_CREDENTIALS_RESPONSE, + token_status=http_client.OK, + token_data=self.SUCCESS_RESPONSE, + ) + credentials = self.make_credentials( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + credential_source=self.CREDENTIAL_SOURCE, + quota_project_id=QUOTA_PROJECT_ID, + scopes=SCOPES, + # Default scopes should be ignored. + default_scopes=["ignored"], + ) + + credentials.refresh(request) + + assert len(request.call_args_list) == 4 + # Fourth request should be sent to GCP STS endpoint. + self.assert_token_request_kwargs( + request.call_args_list[3][1], token_headers, token_request_data + ) + assert credentials.token == self.SUCCESS_RESPONSE["access_token"] + assert credentials.quota_project_id == QUOTA_PROJECT_ID + assert credentials.scopes == SCOPES + assert credentials.default_scopes == ["ignored"] + + @mock.patch( + "google.auth.metrics.python_and_auth_lib_version", + return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, + ) + @mock.patch("google.auth._helpers.utcnow") + def test_refresh_success_without_impersonation_use_default_scopes( + self, utcnow, mock_auth_lib_value + ): + utcnow.return_value = datetime.datetime.strptime( + self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ" + ) + expected_subject_token = self.make_serialized_aws_signed_request( + aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN) + ) + token_headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Basic " + BASIC_AUTH_ENCODING, + "x-goog-api-client": "gl-python/3.7 auth/1.1 google-byoid-sdk sa-impersonation/false config-lifetime/false source/aws", + } + token_request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": AUDIENCE, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "scope": " ".join(SCOPES), + "subject_token": expected_subject_token, + "subject_token_type": SUBJECT_TOKEN_TYPE, + } + request = self.make_mock_request( + region_status=http_client.OK, + region_name=self.AWS_REGION, + role_status=http_client.OK, + role_name=self.AWS_ROLE, + security_credentials_status=http_client.OK, + security_credentials_data=self.AWS_SECURITY_CREDENTIALS_RESPONSE, + token_status=http_client.OK, + token_data=self.SUCCESS_RESPONSE, + ) + credentials = self.make_credentials( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + credential_source=self.CREDENTIAL_SOURCE, + quota_project_id=QUOTA_PROJECT_ID, + scopes=None, + # Default scopes should be used since user specified scopes are none. + default_scopes=SCOPES, + ) + + credentials.refresh(request) + + assert len(request.call_args_list) == 4 + # Fourth request should be sent to GCP STS endpoint. + self.assert_token_request_kwargs( + request.call_args_list[3][1], token_headers, token_request_data + ) + assert credentials.token == self.SUCCESS_RESPONSE["access_token"] + assert credentials.quota_project_id == QUOTA_PROJECT_ID + assert credentials.scopes is None + assert credentials.default_scopes == SCOPES + + @mock.patch( + "google.auth.metrics.token_request_access_token_impersonate", + return_value=IMPERSONATE_ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, + ) + @mock.patch( + "google.auth.metrics.python_and_auth_lib_version", + return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, + ) + @mock.patch("google.auth._helpers.utcnow") + def test_refresh_success_with_impersonation_ignore_default_scopes( + self, utcnow, mock_metrics_header_value, mock_auth_lib_value + ): + utcnow.return_value = datetime.datetime.strptime( + self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ" + ) + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=3600) + ).isoformat("T") + "Z" + expected_subject_token = self.make_serialized_aws_signed_request( + aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN) + ) + token_headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Basic " + BASIC_AUTH_ENCODING, + "x-goog-api-client": "gl-python/3.7 auth/1.1 google-byoid-sdk sa-impersonation/true config-lifetime/false source/aws", + } + token_request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": AUDIENCE, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "scope": "https://www.googleapis.com/auth/iam", + "subject_token": expected_subject_token, + "subject_token_type": SUBJECT_TOKEN_TYPE, + } + # Service account impersonation request/response. + impersonation_response = { + "accessToken": "SA_ACCESS_TOKEN", + "expireTime": expire_time, + } + impersonation_headers = { + "Content-Type": "application/json", + "authorization": "Bearer {}".format(self.SUCCESS_RESPONSE["access_token"]), + "x-goog-user-project": QUOTA_PROJECT_ID, + "x-goog-api-client": IMPERSONATE_ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, + # TODO(negarb): Uncomment and update when trust boundary is supported + # for external account credentials. + # "x-allowed-locations": "0x0", + } + impersonation_request_data = { + "delegates": None, + "scope": SCOPES, + "lifetime": "3600s", + } + request = self.make_mock_request( + region_status=http_client.OK, + region_name=self.AWS_REGION, + role_status=http_client.OK, + role_name=self.AWS_ROLE, + security_credentials_status=http_client.OK, + security_credentials_data=self.AWS_SECURITY_CREDENTIALS_RESPONSE, + token_status=http_client.OK, + token_data=self.SUCCESS_RESPONSE, + impersonation_status=http_client.OK, + impersonation_data=impersonation_response, + ) + credentials = self.make_credentials( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + credential_source=self.CREDENTIAL_SOURCE, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + quota_project_id=QUOTA_PROJECT_ID, + scopes=SCOPES, + # Default scopes should be ignored. + default_scopes=["ignored"], + ) + + credentials.refresh(request) + + assert len(request.call_args_list) == 5 + # Fourth request should be sent to GCP STS endpoint. + self.assert_token_request_kwargs( + request.call_args_list[3][1], token_headers, token_request_data + ) + # Fifth request should be sent to iamcredentials endpoint for service + # account impersonation. + self.assert_impersonation_request_kwargs( + request.call_args_list[4][1], + impersonation_headers, + impersonation_request_data, + ) + assert credentials.token == impersonation_response["accessToken"] + assert credentials.quota_project_id == QUOTA_PROJECT_ID + assert credentials.scopes == SCOPES + assert credentials.default_scopes == ["ignored"] + + @mock.patch( + "google.auth.metrics.token_request_access_token_impersonate", + return_value=IMPERSONATE_ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, + ) + @mock.patch( + "google.auth.metrics.python_and_auth_lib_version", + return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, + ) + @mock.patch("google.auth._helpers.utcnow") + def test_refresh_success_with_impersonation_use_default_scopes( + self, utcnow, mock_metrics_header_value, mock_auth_lib_value + ): + utcnow.return_value = datetime.datetime.strptime( + self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ" + ) + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=3600) + ).isoformat("T") + "Z" + expected_subject_token = self.make_serialized_aws_signed_request( + aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN) + ) + token_headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Basic " + BASIC_AUTH_ENCODING, + "x-goog-api-client": "gl-python/3.7 auth/1.1 google-byoid-sdk sa-impersonation/true config-lifetime/false source/aws", + } + token_request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": AUDIENCE, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "scope": "https://www.googleapis.com/auth/iam", + "subject_token": expected_subject_token, + "subject_token_type": SUBJECT_TOKEN_TYPE, + } + # Service account impersonation request/response. + impersonation_response = { + "accessToken": "SA_ACCESS_TOKEN", + "expireTime": expire_time, + } + impersonation_headers = { + "Content-Type": "application/json", + "authorization": "Bearer {}".format(self.SUCCESS_RESPONSE["access_token"]), + "x-goog-user-project": QUOTA_PROJECT_ID, + "x-goog-api-client": IMPERSONATE_ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, + # "x-allowed-locations": "0x0", + } + impersonation_request_data = { + "delegates": None, + "scope": SCOPES, + "lifetime": "3600s", + } + request = self.make_mock_request( + region_status=http_client.OK, + region_name=self.AWS_REGION, + role_status=http_client.OK, + role_name=self.AWS_ROLE, + security_credentials_status=http_client.OK, + security_credentials_data=self.AWS_SECURITY_CREDENTIALS_RESPONSE, + token_status=http_client.OK, + token_data=self.SUCCESS_RESPONSE, + impersonation_status=http_client.OK, + impersonation_data=impersonation_response, + ) + credentials = self.make_credentials( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + credential_source=self.CREDENTIAL_SOURCE, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + quota_project_id=QUOTA_PROJECT_ID, + scopes=None, + # Default scopes should be used since user specified scopes are none. + default_scopes=SCOPES, + ) + + credentials.refresh(request) + + assert len(request.call_args_list) == 5 + # Fourth request should be sent to GCP STS endpoint. + self.assert_token_request_kwargs( + request.call_args_list[3][1], token_headers, token_request_data + ) + # Fifth request should be sent to iamcredentials endpoint for service + # account impersonation. + self.assert_impersonation_request_kwargs( + request.call_args_list[4][1], + impersonation_headers, + impersonation_request_data, + ) + assert credentials.token == impersonation_response["accessToken"] + assert credentials.quota_project_id == QUOTA_PROJECT_ID + assert credentials.scopes is None + assert credentials.default_scopes == SCOPES + + def test_refresh_with_retrieve_subject_token_error(self): + request = self.make_mock_request(region_status=http_client.BAD_REQUEST) + credentials = self.make_credentials(credential_source=self.CREDENTIAL_SOURCE) + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.refresh(request) + + assert excinfo.match(r"Unable to retrieve AWS region") + + @mock.patch("google.auth._helpers.utcnow") + def test_retrieve_subject_token_success_with_supplier(self, utcnow): + utcnow.return_value = datetime.datetime.strptime( + self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ" + ) + request = self.make_mock_request() + + security_credentials = aws.AwsSecurityCredentials( + ACCESS_KEY_ID, SECRET_ACCESS_KEY + ) + supplier = TestAwsSecurityCredentialsSupplier( + security_credentials=security_credentials, region=self.AWS_REGION + ) + + credentials = self.make_credentials(aws_security_credentials_supplier=supplier) + + subject_token = credentials.retrieve_subject_token(request) + assert subject_token == self.make_serialized_aws_signed_request( + aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY) + ) + + @mock.patch("google.auth._helpers.utcnow") + def test_retrieve_subject_token_success_with_supplier_session_token(self, utcnow): + utcnow.return_value = datetime.datetime.strptime( + self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ" + ) + request = self.make_mock_request() + + security_credentials = aws.AwsSecurityCredentials( + ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN + ) + supplier = TestAwsSecurityCredentialsSupplier( + security_credentials=security_credentials, region=self.AWS_REGION + ) + + credentials = self.make_credentials(aws_security_credentials_supplier=supplier) + + subject_token = credentials.retrieve_subject_token(request) + assert subject_token == self.make_serialized_aws_signed_request( + aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN) + ) + + @mock.patch("google.auth._helpers.utcnow") + def test_retrieve_subject_token_success_with_supplier_correct_context(self, utcnow): + utcnow.return_value = datetime.datetime.strptime( + self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ" + ) + request = self.make_mock_request() + expected_context = external_account.SupplierContext( + SUBJECT_TOKEN_TYPE, AUDIENCE + ) + + security_credentials = aws.AwsSecurityCredentials( + ACCESS_KEY_ID, SECRET_ACCESS_KEY + ) + supplier = TestAwsSecurityCredentialsSupplier( + security_credentials=security_credentials, + region=self.AWS_REGION, + expected_context=expected_context, + ) + + credentials = self.make_credentials(aws_security_credentials_supplier=supplier) + + credentials.retrieve_subject_token(request) + + def test_retrieve_subject_token_error_with_supplier(self): + request = self.make_mock_request() + expected_exception = exceptions.RefreshError("Test error") + supplier = TestAwsSecurityCredentialsSupplier( + region=self.AWS_REGION, credentials_exception=expected_exception + ) + + credentials = self.make_credentials(aws_security_credentials_supplier=supplier) + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.refresh(request) + + assert excinfo.match(r"Test error") + + def test_retrieve_subject_token_error_with_supplier_region(self): + request = self.make_mock_request() + expected_exception = exceptions.RefreshError("Test error") + security_credentials = aws.AwsSecurityCredentials( + ACCESS_KEY_ID, SECRET_ACCESS_KEY + ) + supplier = TestAwsSecurityCredentialsSupplier( + security_credentials=security_credentials, + region_exception=expected_exception, + ) + + credentials = self.make_credentials(aws_security_credentials_supplier=supplier) + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.refresh(request) + + assert excinfo.match(r"Test error") + + @mock.patch( + "google.auth.metrics.python_and_auth_lib_version", + return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, + ) + @mock.patch("google.auth._helpers.utcnow") + def test_refresh_success_with_supplier_with_impersonation( + self, utcnow, mock_auth_lib_value + ): + utcnow.return_value = datetime.datetime.strptime( + self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ" + ) + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=3600) + ).isoformat("T") + "Z" + expected_subject_token = self.make_serialized_aws_signed_request( + aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN) + ) + token_headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Basic " + BASIC_AUTH_ENCODING, + "x-goog-api-client": "gl-python/3.7 auth/1.1 google-byoid-sdk sa-impersonation/true config-lifetime/false source/programmatic", + } + token_request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": AUDIENCE, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "scope": "https://www.googleapis.com/auth/iam", + "subject_token": expected_subject_token, + "subject_token_type": SUBJECT_TOKEN_TYPE, + } + # Service account impersonation request/response. + impersonation_response = { + "accessToken": "SA_ACCESS_TOKEN", + "expireTime": expire_time, + } + impersonation_headers = { + "Content-Type": "application/json", + "authorization": "Bearer {}".format(self.SUCCESS_RESPONSE["access_token"]), + "x-goog-user-project": QUOTA_PROJECT_ID, + "x-goog-api-client": IMPERSONATE_ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, + # "x-allowed-locations": "0x0", + } + impersonation_request_data = { + "delegates": None, + "scope": SCOPES, + "lifetime": "3600s", + } + request = self.make_mock_request( + token_status=http_client.OK, + token_data=self.SUCCESS_RESPONSE, + impersonation_status=http_client.OK, + impersonation_data=impersonation_response, + ) + + supplier = TestAwsSecurityCredentialsSupplier( + security_credentials=aws.AwsSecurityCredentials( + ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN + ), + region=self.AWS_REGION, + ) + + credentials = self.make_credentials( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + aws_security_credentials_supplier=supplier, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + quota_project_id=QUOTA_PROJECT_ID, + scopes=SCOPES, + # Default scopes should be ignored. + default_scopes=["ignored"], + ) + + credentials.refresh(request) + + assert len(request.call_args_list) == 2 + # First request should be sent to GCP STS endpoint. + self.assert_token_request_kwargs( + request.call_args_list[0][1], token_headers, token_request_data + ) + # Second request should be sent to iamcredentials endpoint for service + # account impersonation. + self.assert_impersonation_request_kwargs( + request.call_args_list[1][1], + impersonation_headers, + impersonation_request_data, + ) + assert credentials.token == impersonation_response["accessToken"] + assert credentials.quota_project_id == QUOTA_PROJECT_ID + assert credentials.scopes == SCOPES + assert credentials.default_scopes == ["ignored"] + + @mock.patch( + "google.auth.metrics.python_and_auth_lib_version", + return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, + ) + @mock.patch("google.auth._helpers.utcnow") + def test_refresh_success_with_supplier(self, utcnow, mock_auth_lib_value): + utcnow.return_value = datetime.datetime.strptime( + self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ" + ) + expected_subject_token = self.make_serialized_aws_signed_request( + aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN) + ) + token_headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Basic " + BASIC_AUTH_ENCODING, + "x-goog-api-client": "gl-python/3.7 auth/1.1 google-byoid-sdk sa-impersonation/false config-lifetime/false source/programmatic", + } + token_request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": AUDIENCE, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "scope": " ".join(SCOPES), + "subject_token": expected_subject_token, + "subject_token_type": SUBJECT_TOKEN_TYPE, + } + request = self.make_mock_request( + token_status=http_client.OK, token_data=self.SUCCESS_RESPONSE + ) + + supplier = TestAwsSecurityCredentialsSupplier( + security_credentials=aws.AwsSecurityCredentials( + ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN + ), + region=self.AWS_REGION, + ) + + credentials = self.make_credentials( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + aws_security_credentials_supplier=supplier, + quota_project_id=QUOTA_PROJECT_ID, + scopes=SCOPES, + # Default scopes should be ignored. + default_scopes=["ignored"], + ) + + credentials.refresh(request) + + assert len(request.call_args_list) == 1 + # First request should be sent to GCP STS endpoint. + self.assert_token_request_kwargs( + request.call_args_list[0][1], token_headers, token_request_data + ) + assert credentials.token == self.SUCCESS_RESPONSE["access_token"] + assert credentials.quota_project_id == QUOTA_PROJECT_ID + assert credentials.scopes == SCOPES + assert credentials.default_scopes == ["ignored"] diff --git a/packages/google-auth/tests/test_credentials.py b/packages/google-auth/tests/test_credentials.py new file mode 100644 index 000000000000..1fb880096916 --- /dev/null +++ b/packages/google-auth/tests/test_credentials.py @@ -0,0 +1,477 @@ +# Copyright 2016 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 datetime +import os + +import mock +import pytest # type: ignore + +from google.auth import _helpers +from google.auth import credentials +from google.auth import environment_vars +from google.auth import exceptions +from google.oauth2 import _client + + +class CredentialsImpl(credentials.CredentialsWithTrustBoundary): + def _refresh_token(self, request): + self.token = "refreshed-token" + self.expiry = ( + datetime.datetime.utcnow() + + _helpers.REFRESH_THRESHOLD + + datetime.timedelta(seconds=5) + ) + + def with_quota_project(self, quota_project_id): + raise NotImplementedError() + + def _build_trust_boundary_lookup_url(self): + # Using self.token here to make the URL dynamic for testing purposes + return "http://mock.url/lookup_for_{}".format(self.token) + + +class CredentialsImplWithMetrics(credentials.Credentials): + def refresh(self, request): + self.token = request + + def _metric_header_for_usage(self): + return "foo" + + +def test_credentials_constructor(): + credentials = CredentialsImpl() + assert not credentials.token + assert not credentials.expiry + assert not credentials.expired + assert not credentials.valid + assert credentials.universe_domain == "googleapis.com" + assert not credentials._use_non_blocking_refresh + + +def test_credentials_get_cred_info(): + credentials = CredentialsImpl() + assert not credentials.get_cred_info() + + +def test_with_non_blocking_refresh(): + c = CredentialsImpl() + c.with_non_blocking_refresh() + assert c._use_non_blocking_refresh + + +def test_expired_and_valid(): + credentials = CredentialsImpl() + credentials.token = "token" + + assert credentials.valid + assert not credentials.expired + + # Set the expiration to one second more than now plus the clock skew + # accomodation. These credentials should be valid. + credentials.expiry = ( + _helpers.utcnow() + _helpers.REFRESH_THRESHOLD + datetime.timedelta(seconds=1) + ) + + assert credentials.valid + assert not credentials.expired + + # Set the credentials expiration to now. Because of the clock skew + # accomodation, these credentials should report as expired. + credentials.expiry = _helpers.utcnow() + + assert not credentials.valid + assert credentials.expired + + +def test_before_request(): + credentials = CredentialsImpl() + request = mock.Mock() + headers = {} + + # First call should call refresh, setting the token. + credentials.before_request(request, "http://example.com", "GET", headers) + assert credentials.valid + assert credentials.token == "refreshed-token" + assert headers["authorization"] == "Bearer refreshed-token" + assert "x-allowed-locations" not in headers + + request = mock.Mock() + headers = {} + + # Second call shouldn't call refresh. + credentials.before_request(request, "http://example.com", "GET", headers) + assert credentials.valid + assert credentials.token == "refreshed-token" + assert headers["authorization"] == "Bearer refreshed-token" + assert "x-allowed-locations" not in headers + + +def test_before_request_with_trust_boundary(): + DUMMY_BOUNDARY = "0xA30" + credentials = CredentialsImpl() + credentials._trust_boundary = {"locations": [], "encodedLocations": DUMMY_BOUNDARY} + request = mock.Mock() + headers = {} + + # First call should call refresh, setting the token. + credentials.before_request(request, "http://example.com", "GET", headers) + assert credentials.valid + assert credentials.token == "refreshed-token" + assert headers["authorization"] == "Bearer refreshed-token" + assert headers["x-allowed-locations"] == DUMMY_BOUNDARY + + request = mock.Mock() + headers = {} + + # Second call shouldn't call refresh. + credentials.before_request(request, "http://example.com", "GET", headers) + assert credentials.valid + assert credentials.token == "refreshed-token" + assert headers["authorization"] == "Bearer refreshed-token" + assert headers["x-allowed-locations"] == DUMMY_BOUNDARY + + +def test_before_request_metrics(): + credentials = CredentialsImplWithMetrics() + request = "token" + headers = {} + + credentials.before_request(request, "http://example.com", "GET", headers) + assert headers["x-goog-api-client"] == "foo" + + +def test_anonymous_credentials_ctor(): + anon = credentials.AnonymousCredentials() + assert anon.token is None + assert anon.expiry is None + assert not anon.expired + assert anon.valid + + +def test_anonymous_credentials_refresh(): + anon = credentials.AnonymousCredentials() + request = object() + with pytest.raises(ValueError): + anon.refresh(request) + + +def test_anonymous_credentials_apply_default(): + anon = credentials.AnonymousCredentials() + headers = {} + anon.apply(headers) + assert headers == {} + with pytest.raises(ValueError): + anon.apply(headers, token="TOKEN") + + +def test_anonymous_credentials_before_request(): + anon = credentials.AnonymousCredentials() + request = object() + method = "GET" + url = "https://example.com/api/endpoint" + headers = {} + anon.before_request(request, method, url, headers) + assert headers == {} + + +class ReadOnlyScopedCredentialsImpl(credentials.ReadOnlyScoped, CredentialsImpl): + @property + def requires_scopes(self): + return super(ReadOnlyScopedCredentialsImpl, self).requires_scopes + + +def test_readonly_scoped_credentials_constructor(): + credentials = ReadOnlyScopedCredentialsImpl() + assert credentials._scopes is None + + +def test_readonly_scoped_credentials_scopes(): + credentials = ReadOnlyScopedCredentialsImpl() + credentials._scopes = ["one", "two"] + assert credentials.scopes == ["one", "two"] + assert credentials.has_scopes(["one"]) + assert credentials.has_scopes(["two"]) + assert credentials.has_scopes(["one", "two"]) + assert not credentials.has_scopes(["three"]) + + # Test with default scopes + credentials_with_default = ReadOnlyScopedCredentialsImpl() + credentials_with_default._default_scopes = ["one", "two"] + assert credentials_with_default.has_scopes(["one", "two"]) + assert not credentials_with_default.has_scopes(["three"]) + + # Test with no scopes + credentials_no_scopes = ReadOnlyScopedCredentialsImpl() + assert not credentials_no_scopes.has_scopes(["one"]) + + assert credentials_no_scopes.has_scopes([]) + + +def test_readonly_scoped_credentials_requires_scopes(): + credentials = ReadOnlyScopedCredentialsImpl() + assert not credentials.requires_scopes + + +class RequiresScopedCredentialsImpl(credentials.Scoped, CredentialsImpl): + def __init__(self, scopes=None, default_scopes=None): + super(RequiresScopedCredentialsImpl, self).__init__() + self._scopes = scopes + self._default_scopes = default_scopes + + @property + def requires_scopes(self): + return not self.scopes + + def with_scopes(self, scopes, default_scopes=None): + return RequiresScopedCredentialsImpl( + scopes=scopes, default_scopes=default_scopes + ) + + +def test_create_scoped_if_required_scoped(): + unscoped_credentials = RequiresScopedCredentialsImpl() + scoped_credentials = credentials.with_scopes_if_required( + unscoped_credentials, ["one", "two"] + ) + + assert scoped_credentials is not unscoped_credentials + assert not scoped_credentials.requires_scopes + assert scoped_credentials.has_scopes(["one", "two"]) + + +def test_create_scoped_if_required_not_scopes(): + unscoped_credentials = CredentialsImpl() + scoped_credentials = credentials.with_scopes_if_required( + unscoped_credentials, ["one", "two"] + ) + + assert scoped_credentials is unscoped_credentials + + +def test_nonblocking_refresh_fresh_credentials(): + c = CredentialsImpl() + + c._refresh_worker = mock.MagicMock() + + request = mock.Mock() + + c.refresh(request) + assert c.token_state == credentials.TokenState.FRESH + + c.with_non_blocking_refresh() + c.before_request(request, "http://example.com", "GET", {}) + + +def test_nonblocking_refresh_invalid_credentials(): + c = CredentialsImpl() + c.with_non_blocking_refresh() + + request = mock.Mock() + headers = {} + + assert c.token_state == credentials.TokenState.INVALID + + c.before_request(request, "http://example.com", "GET", headers) + assert c.token_state == credentials.TokenState.FRESH + assert c.valid + assert c.token == "refreshed-token" + assert headers["authorization"] == "Bearer refreshed-token" + assert "x-identity-trust-boundary" not in headers + + +def test_nonblocking_refresh_stale_credentials(): + c = CredentialsImpl() + c.with_non_blocking_refresh() + + request = mock.Mock() + headers = {} + + # Invalid credentials MUST require a blocking refresh. + c.before_request(request, "http://example.com", "GET", headers) + assert c.token_state == credentials.TokenState.FRESH + assert not c._refresh_worker._worker + + c.expiry = ( + datetime.datetime.utcnow() + + _helpers.REFRESH_THRESHOLD + - datetime.timedelta(seconds=1) + ) + + # STALE credentials SHOULD spawn a non-blocking worker + assert c.token_state == credentials.TokenState.STALE + c.before_request(request, "http://example.com", "GET", headers) + assert c._refresh_worker._worker is not None + + assert c.token_state == credentials.TokenState.FRESH + assert c.valid + assert c.token == "refreshed-token" + assert headers["authorization"] == "Bearer refreshed-token" + assert "x-identity-trust-boundary" not in headers + + +def test_nonblocking_refresh_failed_credentials(): + c = CredentialsImpl() + c.with_non_blocking_refresh() + + request = mock.Mock() + headers = {} + + # Invalid credentials MUST require a blocking refresh. + c.before_request(request, "http://example.com", "GET", headers) + assert c.token_state == credentials.TokenState.FRESH + assert not c._refresh_worker._worker + + c.expiry = ( + datetime.datetime.utcnow() + + _helpers.REFRESH_THRESHOLD + - datetime.timedelta(seconds=1) + ) + + # STALE credentials SHOULD spawn a non-blocking worker + assert c.token_state == credentials.TokenState.STALE + c._refresh_worker._worker = mock.MagicMock() + c._refresh_worker._worker._error_info = "Some Error" + c.before_request(request, "http://example.com", "GET", headers) + assert c._refresh_worker._worker is not None + + assert c.token_state == credentials.TokenState.FRESH + assert c.valid + assert c.token == "refreshed-token" + assert headers["authorization"] == "Bearer refreshed-token" + assert "x-identity-trust-boundary" not in headers + + +def test_token_state_no_expiry(): + c = CredentialsImpl() + + request = mock.Mock() + c.refresh(request) + + c.expiry = None + assert c.token_state == credentials.TokenState.FRESH + + c.before_request(request, "http://example.com", "GET", {}) + + +class TestCredentialsWithTrustBoundary(object): + @mock.patch.object(_client, "_lookup_trust_boundary") + def test_lookup_trust_boundary_env_var_not_true(self, mock_lookup_tb): + creds = CredentialsImpl() + request = mock.Mock() + + # Ensure env var is not "true" + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "false"} + ): + result = creds._refresh_trust_boundary(request) + + assert result is None + mock_lookup_tb.assert_not_called() + + @mock.patch.object(_client, "_lookup_trust_boundary") + def test_lookup_trust_boundary_env_var_missing(self, mock_lookup_tb): + creds = CredentialsImpl() + request = mock.Mock() + + # Ensure env var is missing + with mock.patch.dict(os.environ, clear=True): + result = creds._refresh_trust_boundary(request) + + assert result is None + mock_lookup_tb.assert_not_called() + + @mock.patch.object(_client, "_lookup_trust_boundary") + def test_lookup_trust_boundary_non_default_universe(self, mock_lookup_tb): + creds = CredentialsImpl() + creds._universe_domain = "my.universe.com" # Non-GDU + request = mock.Mock() + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + ): + result = creds._refresh_trust_boundary(request) + + assert result is None + mock_lookup_tb.assert_not_called() + + @mock.patch.object(_client, "_lookup_trust_boundary") + def test_lookup_trust_boundary_calls_client_and_build_url(self, mock_lookup_tb): + creds = CredentialsImpl() + creds.token = "test_token" # For _build_trust_boundary_lookup_url + request = mock.Mock() + expected_url = "http://mock.url/lookup_for_test_token" + expected_boundary_info = {"encodedLocations": "0xABC"} + mock_lookup_tb.return_value = expected_boundary_info + + # Mock _build_trust_boundary_lookup_url to ensure it's called. + mock_build_url = mock.Mock(return_value=expected_url) + creds._build_trust_boundary_lookup_url = mock_build_url + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + ): + result = creds._lookup_trust_boundary(request) + + assert result == expected_boundary_info + mock_build_url.assert_called_once() + expected_headers = {"authorization": "Bearer test_token"} + mock_lookup_tb.assert_called_once_with( + request, expected_url, headers=expected_headers + ) + + @mock.patch.object(_client, "_lookup_trust_boundary") + def test_lookup_trust_boundary_build_url_returns_none(self, mock_lookup_tb): + creds = CredentialsImpl() + request = mock.Mock() + + # Mock _build_trust_boundary_lookup_url to return None + mock_build_url = mock.Mock(return_value=None) + creds._build_trust_boundary_lookup_url = mock_build_url + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + ): + with pytest.raises( + exceptions.InvalidValue, + match="Failed to build trust boundary lookup URL.", + ): + creds._lookup_trust_boundary(request) + + mock_build_url.assert_called_once() # Ensure _build_trust_boundary_lookup_url was called + mock_lookup_tb.assert_not_called() # Ensure _client.lookup_trust_boundary was not called + + @mock.patch("google.auth.credentials._LOGGER") + @mock.patch("google.auth._helpers.is_logging_enabled", return_value=True) + @mock.patch.object(_client, "_lookup_trust_boundary") + def test_refresh_trust_boundary_fails_with_cached_data_and_logging( + self, mock_lookup_tb, mock_is_logging_enabled, mock_logger + ): + creds = CredentialsImpl() + creds._trust_boundary = {"encodedLocations": "0xABC"} + request = mock.Mock() + + refresh_error = exceptions.RefreshError("Lookup failed") + mock_lookup_tb.side_effect = refresh_error + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + ): + creds.refresh(request) + + mock_lookup_tb.assert_called_once() + mock_is_logging_enabled.assert_called_once_with(mock_logger) + mock_logger.debug.assert_called_once_with( + "Using cached trust boundary due to refresh error: %s", refresh_error + ) diff --git a/packages/google-auth/tests/test_credentials_async.py b/packages/google-auth/tests/test_credentials_async.py new file mode 100644 index 000000000000..51e4f0611c88 --- /dev/null +++ b/packages/google-auth/tests/test_credentials_async.py @@ -0,0 +1,136 @@ +# Copyright 2024 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 # type: ignore + +from google.auth import exceptions +from google.auth.aio import credentials + + +class CredentialsImpl(credentials.Credentials): + pass + + +def test_credentials_constructor(): + credentials = CredentialsImpl() + assert not credentials.token + + +@pytest.mark.asyncio +async def test_before_request(): + credentials = CredentialsImpl() + request = "water" + headers = {} + credentials.token = "orchid" + + # before_request should not affect the value of the token. + await credentials.before_request(request, "http://example.com", "GET", headers) + assert credentials.token == "orchid" + assert headers["authorization"] == "Bearer orchid" + assert "x-allowed-locations" not in headers + + request = "earth" + headers = {} + + # Second call shouldn't affect token or headers. + await credentials.before_request(request, "http://example.com", "GET", headers) + assert credentials.token == "orchid" + assert headers["authorization"] == "Bearer orchid" + assert "x-allowed-locations" not in headers + + +@pytest.mark.asyncio +async def test_static_credentials_ctor(): + static_creds = credentials.StaticCredentials(token="orchid") + assert static_creds.token == "orchid" + + +@pytest.mark.asyncio +async def test_static_credentials_apply_default(): + static_creds = credentials.StaticCredentials(token="earth") + headers = {} + + await static_creds.apply(headers) + assert headers["authorization"] == "Bearer earth" + + await static_creds.apply(headers, token="orchid") + assert headers["authorization"] == "Bearer orchid" + + +@pytest.mark.asyncio +async def test_static_credentials_before_request(): + static_creds = credentials.StaticCredentials(token="orchid") + request = "water" + headers = {} + + # before_request should not affect the value of the token. + await static_creds.before_request(request, "http://example.com", "GET", headers) + assert static_creds.token == "orchid" + assert headers["authorization"] == "Bearer orchid" + assert "x-allowed-locations" not in headers + + request = "earth" + headers = {} + + # Second call shouldn't affect token or headers. + await static_creds.before_request(request, "http://example.com", "GET", headers) + assert static_creds.token == "orchid" + assert headers["authorization"] == "Bearer orchid" + assert "x-allowed-locations" not in headers + + +@pytest.mark.asyncio +async def test_static_credentials_refresh(): + static_creds = credentials.StaticCredentials(token="orchid") + request = "earth" + + with pytest.raises(exceptions.InvalidOperation) as exc: + await static_creds.refresh(request) + assert exc.match("Static credentials cannot be refreshed.") + + +@pytest.mark.asyncio +async def test_anonymous_credentials_ctor(): + anon = credentials.AnonymousCredentials() + assert anon.token is None + + +@pytest.mark.asyncio +async def test_anonymous_credentials_refresh(): + anon = credentials.AnonymousCredentials() + request = object() + with pytest.raises(exceptions.InvalidOperation) as exc: + await anon.refresh(request) + assert exc.match("Anonymous credentials cannot be refreshed.") + + +@pytest.mark.asyncio +async def test_anonymous_credentials_apply_default(): + anon = credentials.AnonymousCredentials() + headers = {} + await anon.apply(headers) + assert headers == {} + with pytest.raises(ValueError): + await anon.apply(headers, token="orchid") + + +@pytest.mark.asyncio +async def test_anonymous_credentials_before_request(): + anon = credentials.AnonymousCredentials() + request = object() + method = "GET" + url = "https://example.com/api/endpoint" + headers = {} + await anon.before_request(request, method, url, headers) + assert headers == {} diff --git a/packages/google-auth/tests/test_downscoped.py b/packages/google-auth/tests/test_downscoped.py new file mode 100644 index 000000000000..7c8f970182b8 --- /dev/null +++ b/packages/google-auth/tests/test_downscoped.py @@ -0,0 +1,793 @@ +# Copyright 2021 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 datetime +import http.client as http_client +import json +import urllib + +import mock +import pytest # type: ignore + +from google.auth import _helpers +from google.auth import credentials +from google.auth import downscoped +from google.auth import exceptions +from google.auth import transport +from google.auth.credentials import DEFAULT_UNIVERSE_DOMAIN +from google.auth.credentials import TokenState + + +EXPRESSION = ( + "resource.name.startsWith('projects/_/buckets/example-bucket/objects/customer-a')" +) +TITLE = "customer-a-objects" +DESCRIPTION = ( + "Condition to make permissions available for objects starting with customer-a" +) +AVAILABLE_RESOURCE = "//storage.googleapis.com/projects/_/buckets/example-bucket" +AVAILABLE_PERMISSIONS = ["inRole:roles/storage.objectViewer"] + +OTHER_EXPRESSION = ( + "resource.name.startsWith('projects/_/buckets/example-bucket/objects/customer-b')" +) +OTHER_TITLE = "customer-b-objects" +OTHER_DESCRIPTION = ( + "Condition to make permissions available for objects starting with customer-b" +) +OTHER_AVAILABLE_RESOURCE = "//storage.googleapis.com/projects/_/buckets/other-bucket" +OTHER_AVAILABLE_PERMISSIONS = ["inRole:roles/storage.objectCreator"] +QUOTA_PROJECT_ID = "QUOTA_PROJECT_ID" +GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange" +REQUESTED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token" +TOKEN_EXCHANGE_ENDPOINT = "https://sts.googleapis.com/v1/token" +SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token" +SUCCESS_RESPONSE = { + "access_token": "ACCESS_TOKEN", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "Bearer", + "expires_in": 3600, +} +ERROR_RESPONSE = { + "error": "invalid_grant", + "error_description": "Subject token is invalid.", + "error_uri": "https://tools.ietf.org/html/rfc6749", +} +CREDENTIAL_ACCESS_BOUNDARY_JSON = { + "accessBoundary": { + "accessBoundaryRules": [ + { + "availablePermissions": AVAILABLE_PERMISSIONS, + "availableResource": AVAILABLE_RESOURCE, + "availabilityCondition": { + "expression": EXPRESSION, + "title": TITLE, + "description": DESCRIPTION, + }, + } + ] + } +} + + +class SourceCredentials(credentials.Credentials): + 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: + raise exceptions.RefreshError( + "Failed to refresh access token in source credentials." + ) + now = _helpers.utcnow() + self._counter += 1 + self.token = "ACCESS_TOKEN_{}".format(self._counter) + self.expiry = now + datetime.timedelta(seconds=self._expires_in) + + +def make_availability_condition(expression, title=None, description=None): + return downscoped.AvailabilityCondition(expression, title, description) + + +def make_access_boundary_rule( + available_resource, available_permissions, availability_condition=None +): + return downscoped.AccessBoundaryRule( + available_resource, available_permissions, availability_condition + ) + + +def make_credential_access_boundary(rules): + return downscoped.CredentialAccessBoundary(rules) + + +class TestAvailabilityCondition(object): + def test_constructor(self): + availability_condition = make_availability_condition( + EXPRESSION, TITLE, DESCRIPTION + ) + + assert availability_condition.expression == EXPRESSION + assert availability_condition.title == TITLE + assert availability_condition.description == DESCRIPTION + + def test_constructor_required_params_only(self): + availability_condition = make_availability_condition(EXPRESSION) + + assert availability_condition.expression == EXPRESSION + assert availability_condition.title is None + assert availability_condition.description is None + + def test_setters(self): + availability_condition = make_availability_condition( + EXPRESSION, TITLE, DESCRIPTION + ) + availability_condition.expression = OTHER_EXPRESSION + availability_condition.title = OTHER_TITLE + availability_condition.description = OTHER_DESCRIPTION + + assert availability_condition.expression == OTHER_EXPRESSION + assert availability_condition.title == OTHER_TITLE + assert availability_condition.description == OTHER_DESCRIPTION + + def test_invalid_expression_type(self): + with pytest.raises(TypeError) as excinfo: + make_availability_condition([EXPRESSION], TITLE, DESCRIPTION) + + assert excinfo.match("The provided expression is not a string.") + + def test_invalid_title_type(self): + with pytest.raises(TypeError) as excinfo: + make_availability_condition(EXPRESSION, False, DESCRIPTION) + + assert excinfo.match("The provided title is not a string or None.") + + def test_invalid_description_type(self): + with pytest.raises(TypeError) as excinfo: + make_availability_condition(EXPRESSION, TITLE, False) + + assert excinfo.match("The provided description is not a string or None.") + + def test_to_json_required_params_only(self): + availability_condition = make_availability_condition(EXPRESSION) + + assert availability_condition.to_json() == {"expression": EXPRESSION} + + def test_to_json_(self): + availability_condition = make_availability_condition( + EXPRESSION, TITLE, DESCRIPTION + ) + + assert availability_condition.to_json() == { + "expression": EXPRESSION, + "title": TITLE, + "description": DESCRIPTION, + } + + +class TestAccessBoundaryRule(object): + def test_constructor(self): + availability_condition = make_availability_condition( + EXPRESSION, TITLE, DESCRIPTION + ) + access_boundary_rule = make_access_boundary_rule( + AVAILABLE_RESOURCE, AVAILABLE_PERMISSIONS, availability_condition + ) + + assert access_boundary_rule.available_resource == AVAILABLE_RESOURCE + assert access_boundary_rule.available_permissions == tuple( + AVAILABLE_PERMISSIONS + ) + assert access_boundary_rule.availability_condition == availability_condition + + def test_constructor_required_params_only(self): + access_boundary_rule = make_access_boundary_rule( + AVAILABLE_RESOURCE, AVAILABLE_PERMISSIONS + ) + + assert access_boundary_rule.available_resource == AVAILABLE_RESOURCE + assert access_boundary_rule.available_permissions == tuple( + AVAILABLE_PERMISSIONS + ) + assert access_boundary_rule.availability_condition is None + + def test_setters(self): + availability_condition = make_availability_condition( + EXPRESSION, TITLE, DESCRIPTION + ) + other_availability_condition = make_availability_condition( + OTHER_EXPRESSION, OTHER_TITLE, OTHER_DESCRIPTION + ) + access_boundary_rule = make_access_boundary_rule( + AVAILABLE_RESOURCE, AVAILABLE_PERMISSIONS, availability_condition + ) + access_boundary_rule.available_resource = OTHER_AVAILABLE_RESOURCE + access_boundary_rule.available_permissions = OTHER_AVAILABLE_PERMISSIONS + access_boundary_rule.availability_condition = other_availability_condition + + assert access_boundary_rule.available_resource == OTHER_AVAILABLE_RESOURCE + assert access_boundary_rule.available_permissions == tuple( + OTHER_AVAILABLE_PERMISSIONS + ) + assert ( + access_boundary_rule.availability_condition == other_availability_condition + ) + + def test_invalid_available_resource_type(self): + availability_condition = make_availability_condition( + EXPRESSION, TITLE, DESCRIPTION + ) + with pytest.raises(TypeError) as excinfo: + make_access_boundary_rule( + None, AVAILABLE_PERMISSIONS, availability_condition + ) + + assert excinfo.match("The provided available_resource is not a string.") + + def test_invalid_available_permissions_type(self): + availability_condition = make_availability_condition( + EXPRESSION, TITLE, DESCRIPTION + ) + with pytest.raises(TypeError) as excinfo: + make_access_boundary_rule( + AVAILABLE_RESOURCE, [0, 1, 2], availability_condition + ) + + assert excinfo.match( + "Provided available_permissions are not a list of strings." + ) + + def test_invalid_available_permissions_value(self): + availability_condition = make_availability_condition( + EXPRESSION, TITLE, DESCRIPTION + ) + with pytest.raises(ValueError) as excinfo: + make_access_boundary_rule( + AVAILABLE_RESOURCE, + ["roles/storage.objectViewer"], + availability_condition, + ) + + assert excinfo.match("available_permissions must be prefixed with 'inRole:'.") + + def test_invalid_availability_condition_type(self): + with pytest.raises(TypeError) as excinfo: + make_access_boundary_rule( + AVAILABLE_RESOURCE, AVAILABLE_PERMISSIONS, {"foo": "bar"} + ) + + assert excinfo.match( + "The provided availability_condition is not a 'google.auth.downscoped.AvailabilityCondition' or None." + ) + + def test_to_json(self): + availability_condition = make_availability_condition( + EXPRESSION, TITLE, DESCRIPTION + ) + access_boundary_rule = make_access_boundary_rule( + AVAILABLE_RESOURCE, AVAILABLE_PERMISSIONS, availability_condition + ) + + assert access_boundary_rule.to_json() == { + "availablePermissions": AVAILABLE_PERMISSIONS, + "availableResource": AVAILABLE_RESOURCE, + "availabilityCondition": { + "expression": EXPRESSION, + "title": TITLE, + "description": DESCRIPTION, + }, + } + + def test_to_json_required_params_only(self): + access_boundary_rule = make_access_boundary_rule( + AVAILABLE_RESOURCE, AVAILABLE_PERMISSIONS + ) + + assert access_boundary_rule.to_json() == { + "availablePermissions": AVAILABLE_PERMISSIONS, + "availableResource": AVAILABLE_RESOURCE, + } + + +class TestCredentialAccessBoundary(object): + def test_constructor(self): + availability_condition = make_availability_condition( + EXPRESSION, TITLE, DESCRIPTION + ) + access_boundary_rule = make_access_boundary_rule( + AVAILABLE_RESOURCE, AVAILABLE_PERMISSIONS, availability_condition + ) + rules = [access_boundary_rule] + credential_access_boundary = make_credential_access_boundary(rules) + + assert credential_access_boundary.rules == tuple(rules) + + def test_setters(self): + availability_condition = make_availability_condition( + EXPRESSION, TITLE, DESCRIPTION + ) + access_boundary_rule = make_access_boundary_rule( + AVAILABLE_RESOURCE, AVAILABLE_PERMISSIONS, availability_condition + ) + rules = [access_boundary_rule] + other_availability_condition = make_availability_condition( + OTHER_EXPRESSION, OTHER_TITLE, OTHER_DESCRIPTION + ) + other_access_boundary_rule = make_access_boundary_rule( + OTHER_AVAILABLE_RESOURCE, + OTHER_AVAILABLE_PERMISSIONS, + other_availability_condition, + ) + other_rules = [other_access_boundary_rule] + credential_access_boundary = make_credential_access_boundary(rules) + credential_access_boundary.rules = other_rules + + assert credential_access_boundary.rules == tuple(other_rules) + + def test_add_rule(self): + availability_condition = make_availability_condition( + EXPRESSION, TITLE, DESCRIPTION + ) + access_boundary_rule = make_access_boundary_rule( + AVAILABLE_RESOURCE, AVAILABLE_PERMISSIONS, availability_condition + ) + rules = [access_boundary_rule] * 9 + credential_access_boundary = make_credential_access_boundary(rules) + + # Add one more rule. This should not raise an error. + additional_access_boundary_rule = make_access_boundary_rule( + OTHER_AVAILABLE_RESOURCE, OTHER_AVAILABLE_PERMISSIONS + ) + credential_access_boundary.add_rule(additional_access_boundary_rule) + + assert len(credential_access_boundary.rules) == 10 + assert credential_access_boundary.rules[9] == additional_access_boundary_rule + + def test_add_rule_invalid_value(self): + availability_condition = make_availability_condition( + EXPRESSION, TITLE, DESCRIPTION + ) + access_boundary_rule = make_access_boundary_rule( + AVAILABLE_RESOURCE, AVAILABLE_PERMISSIONS, availability_condition + ) + rules = [access_boundary_rule] * 10 + credential_access_boundary = make_credential_access_boundary(rules) + + # Add one more rule to exceed maximum allowed rules. + with pytest.raises(ValueError) as excinfo: + credential_access_boundary.add_rule(access_boundary_rule) + + assert excinfo.match( + "Credential access boundary rules can have a maximum of 10 rules." + ) + assert len(credential_access_boundary.rules) == 10 + + def test_add_rule_invalid_type(self): + availability_condition = make_availability_condition( + EXPRESSION, TITLE, DESCRIPTION + ) + access_boundary_rule = make_access_boundary_rule( + AVAILABLE_RESOURCE, AVAILABLE_PERMISSIONS, availability_condition + ) + rules = [access_boundary_rule] + credential_access_boundary = make_credential_access_boundary(rules) + + # Add an invalid rule to exceed maximum allowed rules. + with pytest.raises(TypeError) as excinfo: + credential_access_boundary.add_rule("invalid") + + assert excinfo.match( + "The provided rule does not contain a valid 'google.auth.downscoped.AccessBoundaryRule'." + ) + assert len(credential_access_boundary.rules) == 1 + assert credential_access_boundary.rules[0] == access_boundary_rule + + def test_invalid_rules_type(self): + with pytest.raises(TypeError) as excinfo: + make_credential_access_boundary(["invalid"]) + + assert excinfo.match( + "List of rules provided do not contain a valid 'google.auth.downscoped.AccessBoundaryRule'." + ) + + def test_invalid_rules_value(self): + availability_condition = make_availability_condition( + EXPRESSION, TITLE, DESCRIPTION + ) + access_boundary_rule = make_access_boundary_rule( + AVAILABLE_RESOURCE, AVAILABLE_PERMISSIONS, availability_condition + ) + too_many_rules = [access_boundary_rule] * 11 + with pytest.raises(ValueError) as excinfo: + make_credential_access_boundary(too_many_rules) + + assert excinfo.match( + "Credential access boundary rules can have a maximum of 10 rules." + ) + + def test_to_json(self): + availability_condition = make_availability_condition( + EXPRESSION, TITLE, DESCRIPTION + ) + access_boundary_rule = make_access_boundary_rule( + AVAILABLE_RESOURCE, AVAILABLE_PERMISSIONS, availability_condition + ) + rules = [access_boundary_rule] + credential_access_boundary = make_credential_access_boundary(rules) + + assert credential_access_boundary.to_json() == { + "accessBoundary": { + "accessBoundaryRules": [ + { + "availablePermissions": AVAILABLE_PERMISSIONS, + "availableResource": AVAILABLE_RESOURCE, + "availabilityCondition": { + "expression": EXPRESSION, + "title": TITLE, + "description": DESCRIPTION, + }, + } + ] + } + } + + +class TestCredentials(object): + @staticmethod + def make_credentials( + source_credentials=SourceCredentials(), + quota_project_id=None, + universe_domain=None, + ): + availability_condition = make_availability_condition( + EXPRESSION, TITLE, DESCRIPTION + ) + access_boundary_rule = make_access_boundary_rule( + AVAILABLE_RESOURCE, AVAILABLE_PERMISSIONS, availability_condition + ) + rules = [access_boundary_rule] + credential_access_boundary = make_credential_access_boundary(rules) + + return downscoped.Credentials( + source_credentials, + credential_access_boundary, + quota_project_id, + universe_domain, + ) + + @staticmethod + def make_mock_request(data, status=http_client.OK): + response = mock.create_autospec(transport.Response, instance=True) + response.status = status + response.data = json.dumps(data).encode("utf-8") + + request = mock.create_autospec(transport.Request) + request.return_value = response + + return request + + @staticmethod + def assert_request_kwargs( + request_kwargs, headers, request_data, token_endpoint=TOKEN_EXCHANGE_ENDPOINT + ): + """Asserts the request was called with the expected parameters.""" + assert request_kwargs["url"] == token_endpoint + assert request_kwargs["method"] == "POST" + assert request_kwargs["headers"] == headers + assert request_kwargs["body"] is not None + body_tuples = urllib.parse.parse_qsl(request_kwargs["body"]) + for k, v in body_tuples: + assert v.decode("utf-8") == request_data[k.decode("utf-8")] + assert len(body_tuples) == len(request_data.keys()) + + def test_default_state(self): + credentials = self.make_credentials() + + # No token acquired yet. + assert not credentials.token + assert not credentials.valid + # Expiration hasn't been set yet. + assert not credentials.expiry + assert not credentials.expired + # No quota project ID set. + assert not credentials.quota_project_id + assert credentials.universe_domain == DEFAULT_UNIVERSE_DOMAIN + + def test_default_state_with_explicit_none_value(self): + credentials = self.make_credentials(universe_domain=None) + + # No token acquired yet. + assert not credentials.token + assert not credentials.valid + # Expiration hasn't been set yet. + assert not credentials.expiry + assert not credentials.expired + # No quota project ID set. + assert not credentials.quota_project_id + assert credentials.universe_domain == DEFAULT_UNIVERSE_DOMAIN + + def test_create_with_customized_universe_domain(self): + test_universe_domain = "foo.com" + credentials = self.make_credentials(universe_domain=test_universe_domain) + # No token acquired yet. + assert not credentials.token + assert not credentials.valid + # Expiration hasn't been set yet. + assert not credentials.expiry + assert not credentials.expired + # No quota project ID set. + assert not credentials.quota_project_id + assert credentials.universe_domain == test_universe_domain + + def test_with_quota_project(self): + credentials = self.make_credentials() + + assert not credentials.quota_project_id + + quota_project_creds = credentials.with_quota_project("project-foo") + + assert quota_project_creds.quota_project_id == "project-foo" + + @mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) + def test_refresh_on_custom_universe(self, unused_utcnow): + test_universe_domain = "foo.com" + response = SUCCESS_RESPONSE.copy() + # Test custom expiration to confirm expiry is set correctly. + response["expires_in"] = 2800 + expected_expiry = datetime.datetime.min + datetime.timedelta( + seconds=response["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) + source_credentials = SourceCredentials() + credentials = self.make_credentials( + source_credentials=source_credentials, universe_domain=test_universe_domain + ) + token_exchange_endpoint = downscoped._STS_TOKEN_URL_PATTERN.format( + test_universe_domain + ) + + # 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, token_exchange_endpoint + ) + 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) + + @mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) + def test_refresh(self, unused_utcnow): + response = SUCCESS_RESPONSE.copy() + # Test custom expiration to confirm expiry is set correctly. + response["expires_in"] = 2800 + expected_expiry = datetime.datetime.min + datetime.timedelta( + seconds=response["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) + source_credentials = SourceCredentials() + 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) + + @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 + ) + credentials = self.make_credentials() + + with pytest.raises(exceptions.OAuthError) as excinfo: + credentials.refresh(request) + + assert excinfo.match( + r"Error code invalid_grant: Subject token is invalid. - https://tools.ietf.org/html/rfc6749" + ) + assert not credentials.expired + assert credentials.token is None + + def test_refresh_source_credentials_refresh_error(self): + # Initialize downscoped credentials with source credentials that raise + # an error on refresh. + credentials = self.make_credentials( + source_credentials=SourceCredentials(raise_error=True) + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.refresh(mock.sentinel.request) + + assert excinfo.match(r"Failed to refresh access token in source credentials.") + assert not credentials.expired + assert credentials.token is None + + def test_apply_without_quota_project_id(self): + headers = {} + request = self.make_mock_request(status=http_client.OK, data=SUCCESS_RESPONSE) + credentials = self.make_credentials() + + credentials.refresh(request) + credentials.apply(headers) + + assert headers == { + "authorization": "Bearer {}".format(SUCCESS_RESPONSE["access_token"]) + } + + def test_apply_with_quota_project_id(self): + headers = {"other": "header-value"} + request = self.make_mock_request(status=http_client.OK, data=SUCCESS_RESPONSE) + credentials = self.make_credentials(quota_project_id=QUOTA_PROJECT_ID) + + credentials.refresh(request) + credentials.apply(headers) + + assert headers == { + "other": "header-value", + "authorization": "Bearer {}".format(SUCCESS_RESPONSE["access_token"]), + "x-goog-user-project": QUOTA_PROJECT_ID, + } + + def test_before_request(self): + headers = {"other": "header-value"} + request = self.make_mock_request(status=http_client.OK, data=SUCCESS_RESPONSE) + credentials = self.make_credentials() + + # First call should call refresh, setting the token. + credentials.before_request(request, "POST", "https://example.com/api", headers) + + assert headers == { + "other": "header-value", + "authorization": "Bearer {}".format(SUCCESS_RESPONSE["access_token"]), + } + + # Second call shouldn't call refresh (request should be untouched). + credentials.before_request( + mock.sentinel.request, "POST", "https://example.com/api", headers + ) + + assert headers == { + "other": "header-value", + "authorization": "Bearer {}".format(SUCCESS_RESPONSE["access_token"]), + } + + @mock.patch("google.auth._helpers.utcnow") + def test_before_request_expired(self, utcnow): + headers = {} + request = self.make_mock_request(status=http_client.OK, data=SUCCESS_RESPONSE) + credentials = self.make_credentials() + credentials.token = "token" + utcnow.return_value = datetime.datetime.min + # Set the expiration to one second more than now plus the clock skew + # accommodation. These credentials should be valid. + credentials.expiry = ( + datetime.datetime.min + + _helpers.REFRESH_THRESHOLD + + datetime.timedelta(seconds=1) + ) + + assert credentials.valid + assert not credentials.expired + assert credentials.token_state == TokenState.FRESH + + credentials.before_request(request, "POST", "https://example.com/api", headers) + + # Cached token should be used. + assert headers == {"authorization": "Bearer token"} + + # Next call should simulate 1 second passed. + utcnow.return_value = datetime.datetime.min + datetime.timedelta(seconds=1) + + assert not credentials.valid + assert credentials.expired + assert credentials.token_state == TokenState.STALE + + credentials.before_request(request, "POST", "https://example.com/api", headers) + assert credentials.token_state == TokenState.FRESH + + # New token should be retrieved. + assert headers == { + "authorization": "Bearer {}".format(SUCCESS_RESPONSE["access_token"]) + } + + utcnow.return_value = datetime.datetime.min + datetime.timedelta(seconds=6000) + + assert not credentials.valid + assert credentials.expired + assert credentials.token_state == TokenState.INVALID + + credentials.before_request(request, "POST", "https://example.com/api", headers) + assert credentials.token_state == TokenState.FRESH + + # New token should be retrieved. + assert headers == { + "authorization": "Bearer {}".format(SUCCESS_RESPONSE["access_token"]) + } diff --git a/packages/google-auth/tests/test_exceptions.py b/packages/google-auth/tests/test_exceptions.py new file mode 100644 index 000000000000..6f542498fc5c --- /dev/null +++ b/packages/google-auth/tests/test_exceptions.py @@ -0,0 +1,55 @@ +# Copyright 2022 Google Inc. +# +# 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 # type: ignore + +from google.auth import exceptions # type:ignore + + +@pytest.fixture( + params=[ + exceptions.GoogleAuthError, + exceptions.TransportError, + exceptions.RefreshError, + exceptions.UserAccessTokenError, + exceptions.DefaultCredentialsError, + exceptions.MutualTLSChannelError, + exceptions.OAuthError, + exceptions.ReauthFailError, + exceptions.ReauthSamlChallengeFailError, + ] +) +def retryable_exception(request): + return request.param + + +@pytest.fixture(params=[exceptions.ClientCertError]) +def non_retryable_exception(request): + return request.param + + +def test_default_retryable_exceptions(retryable_exception): + assert not retryable_exception().retryable + + +@pytest.mark.parametrize("retryable", [True, False]) +def test_retryable_exceptions(retryable_exception, retryable): + retryable_exception = retryable_exception(retryable=retryable) + assert retryable_exception.retryable == retryable + + +@pytest.mark.parametrize("retryable", [True, False]) +def test_non_retryable_exceptions(non_retryable_exception, retryable): + non_retryable_exception = non_retryable_exception(retryable=retryable) + assert not non_retryable_exception.retryable diff --git a/packages/google-auth/tests/test_external_account.py b/packages/google-auth/tests/test_external_account.py new file mode 100644 index 000000000000..2fa64361db88 --- /dev/null +++ b/packages/google-auth/tests/test_external_account.py @@ -0,0 +1,2410 @@ +# 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 datetime +import http.client as http_client +import json +import os +import urllib + +import mock +import pytest # type: ignore + +from google.auth import _helpers +from google.auth import environment_vars +from google.auth import exceptions +from google.auth import external_account +from google.auth import transport +from google.auth.credentials import DEFAULT_UNIVERSE_DOMAIN +from google.auth.credentials import TokenState + +IMPERSONATE_ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE = ( + "gl-python/3.7 auth/1.1 auth-request-type/at cred-type/imp" +) +LANG_LIBRARY_METRICS_HEADER_VALUE = "gl-python/3.7 auth/1.1" + +CLIENT_ID = "username" +CLIENT_SECRET = "password" +# Base64 encoding of "username:password" +BASIC_AUTH_ENCODING = "dXNlcm5hbWU6cGFzc3dvcmQ=" +SERVICE_ACCOUNT_EMAIL = "service-1234@service-name.iam.gserviceaccount.com" +# List of valid workforce pool audiences. +TEST_USER_AUDIENCES = [ + "//iam.googleapis.com/locations/global/workforcePools/pool-id/providers/provider-id", + "//iam.googleapis.com/locations/eu/workforcePools/pool-id/providers/provider-id", + "//iam.googleapis.com/locations/eu/workforcePools/workloadIdentityPools/providers/provider-id", +] +# Workload identity pool audiences or invalid workforce pool audiences. +TEST_NON_USER_AUDIENCES = [ + # Legacy K8s audience format. + "identitynamespace:1f12345:my_provider", + ( + "//iam.googleapis.com/projects/123456/locations/" + "global/workloadIdentityPools/pool-id/providers/" + "provider-id" + ), + ( + "//iam.googleapis.com/projects/123456/locations/" + "eu/workloadIdentityPools/pool-id/providers/" + "provider-id" + ), + # Pool ID with workforcePools string. + ( + "//iam.googleapis.com/projects/123456/locations/" + "global/workloadIdentityPools/workforcePools/providers/" + "provider-id" + ), + # Unrealistic / incorrect workforce pool audiences. + "//iamgoogleapis.com/locations/eu/workforcePools/pool-id/providers/provider-id", + "//iam.googleapiscom/locations/eu/workforcePools/pool-id/providers/provider-id", + "//iam.googleapis.com/locations/workforcePools/pool-id/providers/provider-id", + "//iam.googleapis.com/locations/eu/workforcePool/pool-id/providers/provider-id", + "//iam.googleapis.com/locations//workforcePool/pool-id/providers/provider-id", +] + + +class CredentialsImpl(external_account.Credentials): + def __init__(self, **kwargs): + super(CredentialsImpl, self).__init__(**kwargs) + self._counter = 0 + + def retrieve_subject_token(self, request): + counter = self._counter + self._counter += 1 + return "subject_token_{}".format(counter) + + +class TestCredentials(object): + TOKEN_URL = "https://sts.googleapis.com/v1/token" + TOKEN_INFO_URL = "https://sts.googleapis.com/v1/introspect" + PROJECT_NUMBER = "123456" + POOL_ID = "POOL_ID" + PROVIDER_ID = "PROVIDER_ID" + AUDIENCE = ( + "//iam.googleapis.com/projects/{}" + "/locations/global/workloadIdentityPools/{}" + "/providers/{}" + ).format(PROJECT_NUMBER, POOL_ID, PROVIDER_ID) + WORKFORCE_AUDIENCE = ( + "//iam.googleapis.com/locations/global/workforcePools/{}/providers/{}" + ).format(POOL_ID, PROVIDER_ID) + WORKFORCE_POOL_USER_PROJECT = "WORKFORCE_POOL_USER_PROJECT_NUMBER" + SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt" + WORKFORCE_SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id_token" + CREDENTIAL_SOURCE = {"file": "/var/run/secrets/goog.id/token"} + SUCCESS_RESPONSE = { + "access_token": "ACCESS_TOKEN", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "scope1 scope2", + } + ERROR_RESPONSE = { + "error": "invalid_request", + "error_description": "Invalid subject token", + "error_uri": "https://tools.ietf.org/html/rfc6749", + } + QUOTA_PROJECT_ID = "QUOTA_PROJECT_ID" + SERVICE_ACCOUNT_IMPERSONATION_URL = ( + "https://us-east1-iamcredentials.googleapis.com/v1/projects/-" + + "/serviceAccounts/{}:generateAccessToken".format(SERVICE_ACCOUNT_EMAIL) + ) + SCOPES = ["scope1", "scope2"] + IMPERSONATION_ERROR_RESPONSE = { + "error": { + "code": 400, + "message": "Request contains an invalid argument", + "status": "INVALID_ARGUMENT", + } + } + NO_OP_TRUST_BOUNDARY = {"locations": [], "encodedLocations": "0x0"} + VALID_TRUST_BOUNDARY = { + "locations": ["us-central1", "us-east1"], + "encodedLocations": "0xVALIDHEXSA", + } + PROJECT_ID = "my-proj-id" + CLOUD_RESOURCE_MANAGER_URL = ( + "https://cloudresourcemanager.googleapis.com/v1/projects/" + ) + CLOUD_RESOURCE_MANAGER_SUCCESS_RESPONSE = { + "projectNumber": PROJECT_NUMBER, + "projectId": PROJECT_ID, + "lifecycleState": "ACTIVE", + "name": "project-name", + "createTime": "2018-11-06T04:42:54.109Z", + "parent": {"type": "folder", "id": "12345678901"}, + } + + @classmethod + def make_credentials( + cls, + client_id=None, + client_secret=None, + quota_project_id=None, + token_info_url=None, + scopes=None, + default_scopes=None, + service_account_impersonation_url=None, + service_account_impersonation_options={}, + universe_domain=DEFAULT_UNIVERSE_DOMAIN, + trust_boundary=None, + ): + return CredentialsImpl( + audience=cls.AUDIENCE, + subject_token_type=cls.SUBJECT_TOKEN_TYPE, + token_url=cls.TOKEN_URL, + token_info_url=token_info_url, + service_account_impersonation_url=service_account_impersonation_url, + service_account_impersonation_options=service_account_impersonation_options, + credential_source=cls.CREDENTIAL_SOURCE, + client_id=client_id, + client_secret=client_secret, + quota_project_id=quota_project_id, + scopes=scopes, + default_scopes=default_scopes, + universe_domain=universe_domain, + trust_boundary=trust_boundary, + ) + + @classmethod + def make_workforce_pool_credentials( + cls, + client_id=None, + client_secret=None, + quota_project_id=None, + scopes=None, + default_scopes=None, + service_account_impersonation_url=None, + workforce_pool_user_project=None, + trust_boundary=None, + ): + return CredentialsImpl( + audience=cls.WORKFORCE_AUDIENCE, + subject_token_type=cls.WORKFORCE_SUBJECT_TOKEN_TYPE, + token_url=cls.TOKEN_URL, + service_account_impersonation_url=service_account_impersonation_url, + credential_source=cls.CREDENTIAL_SOURCE, + client_id=client_id, + client_secret=client_secret, + quota_project_id=quota_project_id, + scopes=scopes, + default_scopes=default_scopes, + workforce_pool_user_project=workforce_pool_user_project, + trust_boundary=trust_boundary, + ) + + @classmethod + def make_mock_request( + cls, + status=http_client.OK, + data=None, + impersonation_status=None, + impersonation_data=None, + cloud_resource_manager_status=None, + cloud_resource_manager_data=None, + ): + # STS token exchange request. + token_response = mock.create_autospec(transport.Response, instance=True) + token_response.status = status + token_response.data = json.dumps(data).encode("utf-8") + responses = [token_response] + + # If service account impersonation is requested, mock the expected response. + if impersonation_status: + impersonation_response = mock.create_autospec( + transport.Response, instance=True + ) + impersonation_response.status = impersonation_status + impersonation_response.data = json.dumps(impersonation_data).encode("utf-8") + responses.append(impersonation_response) + + # If cloud resource manager is requested, mock the expected response. + if cloud_resource_manager_status: + cloud_resource_manager_response = mock.create_autospec( + transport.Response, instance=True + ) + cloud_resource_manager_response.status = cloud_resource_manager_status + cloud_resource_manager_response.data = json.dumps( + cloud_resource_manager_data + ).encode("utf-8") + responses.append(cloud_resource_manager_response) + + request = mock.create_autospec(transport.Request) + request.side_effect = responses + + return request + + @classmethod + def assert_token_request_kwargs( + cls, request_kwargs, headers, request_data, cert=None + ): + assert request_kwargs["url"] == cls.TOKEN_URL + assert request_kwargs["method"] == "POST" + assert request_kwargs["headers"] == headers + if cert is not None: + assert request_kwargs["cert"] == cert + else: + assert "cert" not in request_kwargs + assert request_kwargs["body"] is not None + body_tuples = urllib.parse.parse_qsl(request_kwargs["body"]) + for k, v in body_tuples: + assert v.decode("utf-8") == request_data[k.decode("utf-8")] + assert len(body_tuples) == len(request_data.keys()) + + @classmethod + def assert_impersonation_request_kwargs( + cls, request_kwargs, headers, request_data, cert=None + ): + assert request_kwargs["url"] == cls.SERVICE_ACCOUNT_IMPERSONATION_URL + assert request_kwargs["method"] == "POST" + assert request_kwargs["headers"] == headers + if cert is not None: + assert request_kwargs["cert"] == cert + else: + assert "cert" not in request_kwargs + assert request_kwargs["body"] is not None + body_json = json.loads(request_kwargs["body"].decode("utf-8")) + assert body_json == request_data + + @classmethod + def assert_resource_manager_request_kwargs( + cls, request_kwargs, project_number, headers + ): + assert request_kwargs["url"] == cls.CLOUD_RESOURCE_MANAGER_URL + project_number + assert request_kwargs["method"] == "GET" + assert request_kwargs["headers"] == headers + assert "body" not in request_kwargs + + def test_get_cred_info(self): + credentials = self.make_credentials() + assert not credentials.get_cred_info() + + credentials._cred_file_path = "/path/to/file" + assert credentials.get_cred_info() == { + "credential_source": "/path/to/file", + "credential_type": "external account credentials", + } + + credentials._service_account_impersonation_url = ( + self.SERVICE_ACCOUNT_IMPERSONATION_URL + ) + assert credentials.get_cred_info() == { + "credential_source": "/path/to/file", + "credential_type": "external account credentials", + "principal": SERVICE_ACCOUNT_EMAIL, + } + + def test__make_copy_get_cred_info(self): + credentials = self.make_credentials() + credentials._cred_file_path = "/path/to/file" + cred_copy = credentials._make_copy() + assert cred_copy._cred_file_path == "/path/to/file" + + def test_default_state(self): + credentials = self.make_credentials( + service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL + ) + + # Token url and service account impersonation url should be set + assert credentials._token_url + assert credentials._service_account_impersonation_url + # Not token acquired yet + assert not credentials.token + assert not credentials.valid + # Expiration hasn't been set yet + assert not credentials.expiry + assert not credentials.expired + # Scopes are required + assert not credentials.scopes + assert credentials.requires_scopes + assert not credentials.quota_project_id + # Token info url not set yet + assert not credentials.token_info_url + + def test_nonworkforce_with_workforce_pool_user_project(self): + with pytest.raises(exceptions.InvalidValue) as excinfo: + CredentialsImpl( + audience=self.AUDIENCE, + subject_token_type=self.SUBJECT_TOKEN_TYPE, + token_url=self.TOKEN_URL, + credential_source=self.CREDENTIAL_SOURCE, + workforce_pool_user_project=self.WORKFORCE_POOL_USER_PROJECT, + ) + + assert excinfo.match( + "workforce_pool_user_project should not be set for non-workforce " + "pool credentials" + ) + + def test_with_scopes(self): + credentials = self.make_credentials() + + assert not credentials.scopes + assert credentials.requires_scopes + + scoped_credentials = credentials.with_scopes(["email"]) + + assert scoped_credentials.has_scopes(["email"]) + assert not scoped_credentials.requires_scopes + + def test_with_scopes_workforce_pool(self): + credentials = self.make_workforce_pool_credentials( + workforce_pool_user_project=self.WORKFORCE_POOL_USER_PROJECT + ) + + assert not credentials.scopes + assert credentials.requires_scopes + + scoped_credentials = credentials.with_scopes(["email"]) + + assert scoped_credentials.has_scopes(["email"]) + assert not scoped_credentials.requires_scopes + assert ( + scoped_credentials.info.get("workforce_pool_user_project") + == self.WORKFORCE_POOL_USER_PROJECT + ) + + def test_with_scopes_using_user_and_default_scopes(self): + credentials = self.make_credentials() + + assert not credentials.scopes + assert credentials.requires_scopes + + scoped_credentials = credentials.with_scopes( + ["email"], default_scopes=["profile"] + ) + + assert scoped_credentials.has_scopes(["email"]) + assert not scoped_credentials.has_scopes(["profile"]) + assert not scoped_credentials.requires_scopes + assert scoped_credentials.scopes == ["email"] + assert scoped_credentials.default_scopes == ["profile"] + + def test_with_scopes_using_default_scopes_only(self): + credentials = self.make_credentials() + + assert not credentials.scopes + assert credentials.requires_scopes + + scoped_credentials = credentials.with_scopes(None, default_scopes=["profile"]) + + assert scoped_credentials.has_scopes(["profile"]) + assert not scoped_credentials.requires_scopes + + def test_with_scopes_full_options_propagated(self): + credentials = self.make_credentials( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + quota_project_id=self.QUOTA_PROJECT_ID, + scopes=self.SCOPES, + token_info_url=self.TOKEN_INFO_URL, + default_scopes=["default1"], + service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, + service_account_impersonation_options={"token_lifetime_seconds": 2800}, + ) + + with mock.patch.object( + external_account.Credentials, "__init__", return_value=None + ) as mock_init: + credentials.with_scopes(["email"], ["default2"]) + + # Confirm with_scopes initialized the credential with the expected + # parameters and scopes. + mock_init.assert_called_once_with( + audience=self.AUDIENCE, + subject_token_type=self.SUBJECT_TOKEN_TYPE, + token_url=self.TOKEN_URL, + token_info_url=self.TOKEN_INFO_URL, + credential_source=self.CREDENTIAL_SOURCE, + service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, + service_account_impersonation_options={"token_lifetime_seconds": 2800}, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + quota_project_id=self.QUOTA_PROJECT_ID, + scopes=["email"], + default_scopes=["default2"], + universe_domain=DEFAULT_UNIVERSE_DOMAIN, + trust_boundary=None, + ) + + def test_with_token_uri(self): + credentials = self.make_credentials() + new_token_uri = "https://eu-sts.googleapis.com/v1/token" + + assert credentials._token_url == self.TOKEN_URL + + creds_with_new_token_uri = credentials.with_token_uri(new_token_uri) + + assert creds_with_new_token_uri._token_url == new_token_uri + + def test_with_token_uri_workforce_pool(self): + credentials = self.make_workforce_pool_credentials( + workforce_pool_user_project=self.WORKFORCE_POOL_USER_PROJECT + ) + + new_token_uri = "https://eu-sts.googleapis.com/v1/token" + + assert credentials._token_url == self.TOKEN_URL + + creds_with_new_token_uri = credentials.with_token_uri(new_token_uri) + + assert creds_with_new_token_uri._token_url == new_token_uri + assert ( + creds_with_new_token_uri.info.get("workforce_pool_user_project") + == self.WORKFORCE_POOL_USER_PROJECT + ) + + def test_with_quota_project(self): + credentials = self.make_credentials() + + assert not credentials.scopes + assert not credentials.quota_project_id + + quota_project_creds = credentials.with_quota_project("project-foo") + + assert quota_project_creds.quota_project_id == "project-foo" + + def test_with_quota_project_workforce_pool(self): + credentials = self.make_workforce_pool_credentials( + workforce_pool_user_project=self.WORKFORCE_POOL_USER_PROJECT + ) + + assert not credentials.scopes + assert not credentials.quota_project_id + + quota_project_creds = credentials.with_quota_project("project-foo") + + assert quota_project_creds.quota_project_id == "project-foo" + assert ( + quota_project_creds.info.get("workforce_pool_user_project") + == self.WORKFORCE_POOL_USER_PROJECT + ) + + def test_with_quota_project_full_options_propagated(self): + credentials = self.make_credentials( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + token_info_url=self.TOKEN_INFO_URL, + quota_project_id=self.QUOTA_PROJECT_ID, + scopes=self.SCOPES, + default_scopes=["default1"], + service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, + service_account_impersonation_options={"token_lifetime_seconds": 2800}, + ) + + with mock.patch.object( + external_account.Credentials, "__init__", return_value=None + ) as mock_init: + new_cred = credentials.with_quota_project("project-foo") + + # Confirm with_quota_project initialized the credential with the + # expected parameters. + mock_init.assert_called_once_with( + audience=self.AUDIENCE, + subject_token_type=self.SUBJECT_TOKEN_TYPE, + token_url=self.TOKEN_URL, + token_info_url=self.TOKEN_INFO_URL, + credential_source=self.CREDENTIAL_SOURCE, + service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, + service_account_impersonation_options={"token_lifetime_seconds": 2800}, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + quota_project_id=self.QUOTA_PROJECT_ID, + scopes=self.SCOPES, + default_scopes=["default1"], + universe_domain=DEFAULT_UNIVERSE_DOMAIN, + trust_boundary=None, + ) + + # Confirm with_quota_project sets the correct quota project after + # initialization. + assert new_cred.quota_project_id == "project-foo" + + def test_info(self): + credentials = self.make_credentials(universe_domain="dummy_universe.com") + + assert credentials.info == { + "type": "external_account", + "audience": self.AUDIENCE, + "subject_token_type": self.SUBJECT_TOKEN_TYPE, + "token_url": self.TOKEN_URL, + "credential_source": self.CREDENTIAL_SOURCE.copy(), + "universe_domain": "dummy_universe.com", + } + + def test_universe_domain(self): + credentials = self.make_credentials(universe_domain="dummy_universe.com") + assert credentials.universe_domain == "dummy_universe.com" + + credentials = self.make_credentials() + assert credentials.universe_domain == DEFAULT_UNIVERSE_DOMAIN + + def test_with_universe_domain(self): + credentials = self.make_credentials() + new_credentials = credentials.with_universe_domain("dummy_universe.com") + assert new_credentials.universe_domain == "dummy_universe.com" + + def test_info_workforce_pool(self): + credentials = self.make_workforce_pool_credentials( + workforce_pool_user_project=self.WORKFORCE_POOL_USER_PROJECT + ) + + assert credentials.info == { + "type": "external_account", + "audience": self.WORKFORCE_AUDIENCE, + "subject_token_type": self.WORKFORCE_SUBJECT_TOKEN_TYPE, + "token_url": self.TOKEN_URL, + "credential_source": self.CREDENTIAL_SOURCE.copy(), + "workforce_pool_user_project": self.WORKFORCE_POOL_USER_PROJECT, + "universe_domain": DEFAULT_UNIVERSE_DOMAIN, + } + + def test_info_with_full_options(self): + credentials = self.make_credentials( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + quota_project_id=self.QUOTA_PROJECT_ID, + token_info_url=self.TOKEN_INFO_URL, + service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, + service_account_impersonation_options={"token_lifetime_seconds": 2800}, + ) + + assert credentials.info == { + "type": "external_account", + "audience": self.AUDIENCE, + "subject_token_type": self.SUBJECT_TOKEN_TYPE, + "token_url": self.TOKEN_URL, + "token_info_url": self.TOKEN_INFO_URL, + "service_account_impersonation_url": self.SERVICE_ACCOUNT_IMPERSONATION_URL, + "service_account_impersonation": {"token_lifetime_seconds": 2800}, + "credential_source": self.CREDENTIAL_SOURCE.copy(), + "quota_project_id": self.QUOTA_PROJECT_ID, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "universe_domain": DEFAULT_UNIVERSE_DOMAIN, + } + + def test_service_account_email_without_impersonation(self): + credentials = self.make_credentials() + + assert credentials.service_account_email is None + + def test_service_account_email_with_impersonation(self): + credentials = self.make_credentials( + service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL + ) + + assert credentials.service_account_email == SERVICE_ACCOUNT_EMAIL + + @pytest.mark.parametrize("audience", TEST_NON_USER_AUDIENCES) + def test_is_user_with_non_users(self, audience): + credentials = CredentialsImpl( + audience=audience, + subject_token_type=self.SUBJECT_TOKEN_TYPE, + token_url=self.TOKEN_URL, + credential_source=self.CREDENTIAL_SOURCE, + ) + + assert credentials.is_user is False + + @pytest.mark.parametrize("audience", TEST_USER_AUDIENCES) + def test_is_user_with_users(self, audience): + credentials = CredentialsImpl( + audience=audience, + subject_token_type=self.SUBJECT_TOKEN_TYPE, + token_url=self.TOKEN_URL, + credential_source=self.CREDENTIAL_SOURCE, + ) + + assert credentials.is_user is True + + @pytest.mark.parametrize("audience", TEST_USER_AUDIENCES) + def test_is_user_with_users_and_impersonation(self, audience): + # Initialize the credentials with service account impersonation. + credentials = CredentialsImpl( + audience=audience, + subject_token_type=self.SUBJECT_TOKEN_TYPE, + token_url=self.TOKEN_URL, + credential_source=self.CREDENTIAL_SOURCE, + service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, + ) + + # Even though the audience is for a workforce pool, since service account + # impersonation is used, the credentials will represent a service account and + # not a user. + assert credentials.is_user is False + + @pytest.mark.parametrize("audience", TEST_NON_USER_AUDIENCES) + def test_is_workforce_pool_with_non_users(self, audience): + credentials = CredentialsImpl( + audience=audience, + subject_token_type=self.SUBJECT_TOKEN_TYPE, + token_url=self.TOKEN_URL, + credential_source=self.CREDENTIAL_SOURCE, + ) + + assert credentials.is_workforce_pool is False + + @pytest.mark.parametrize("audience", TEST_USER_AUDIENCES) + def test_is_workforce_pool_with_users(self, audience): + credentials = CredentialsImpl( + audience=audience, + subject_token_type=self.SUBJECT_TOKEN_TYPE, + token_url=self.TOKEN_URL, + credential_source=self.CREDENTIAL_SOURCE, + ) + + assert credentials.is_workforce_pool is True + + @pytest.mark.parametrize("audience", TEST_USER_AUDIENCES) + def test_is_workforce_pool_with_users_and_impersonation(self, audience): + # Initialize the credentials with workforce audience and service account + # impersonation. + credentials = CredentialsImpl( + audience=audience, + subject_token_type=self.SUBJECT_TOKEN_TYPE, + token_url=self.TOKEN_URL, + credential_source=self.CREDENTIAL_SOURCE, + service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, + ) + + # Even though impersonation is used, is_workforce_pool should still return True. + assert credentials.is_workforce_pool is True + + @pytest.mark.parametrize("mock_expires_in", [2800, "2800"]) + @mock.patch( + "google.auth.metrics.python_and_auth_lib_version", + return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, + ) + @mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) + def test_refresh_without_client_auth_success( + self, unused_utcnow, mock_auth_lib_value, mock_expires_in + ): + response = self.SUCCESS_RESPONSE.copy() + # Test custom expiration to confirm expiry is set correctly. + response["expires_in"] = mock_expires_in + expected_expiry = datetime.datetime.min + datetime.timedelta( + seconds=int(mock_expires_in) + ) + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "x-goog-api-client": "gl-python/3.7 auth/1.1 google-byoid-sdk sa-impersonation/false config-lifetime/false", + } + request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": self.AUDIENCE, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "subject_token": "subject_token_0", + "subject_token_type": self.SUBJECT_TOKEN_TYPE, + } + request = self.make_mock_request(status=http_client.OK, data=response) + credentials = self.make_credentials() + + credentials.refresh(request) + + self.assert_token_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"] + + @mock.patch("google.auth.external_account.Credentials._lookup_trust_boundary") + def test_refresh_skips_trust_boundary_lookup_when_disabled( + self, mock_lookup_trust_boundary + ): + credentials = self.make_credentials() + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + + credentials.refresh(request) + + assert credentials.valid + assert credentials.token == self.SUCCESS_RESPONSE["access_token"] + mock_lookup_trust_boundary.assert_not_called() + headers_applied = {} + credentials.apply(headers_applied) + assert "x-allowed-locations" not in headers_applied + + def test_refresh_skips_sending_allowed_locations_header_with_trust_boundary(self): + # This test verifies that the x-allowed-locations header is not sent with + # the STS request even if a trust boundary is cached. + trust_boundary_value = {"encodedLocations": "0x12345"} + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "x-goog-api-client": "gl-python/3.7 auth/1.1 google-byoid-sdk sa-impersonation/false config-lifetime/false", + } + request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": self.AUDIENCE, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "subject_token": "subject_token_0", + "subject_token_type": self.SUBJECT_TOKEN_TYPE, + } + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + credentials = self.make_credentials() + # Set a cached trust boundary. + credentials._trust_boundary = trust_boundary_value + + with mock.patch( + "google.auth.metrics.python_and_auth_lib_version", + return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, + ): + credentials.refresh(request) + + self.assert_token_request_kwargs(request.call_args[1], headers, request_data) + + def test_refresh_on_impersonated_credential_skips_parent_trust_boundary_lookup( + self, + ): + # This test verifies that the top-level impersonating credential + # does not perform a trust boundary lookup. + request = self.make_mock_request( + status=http_client.OK, + data=self.SUCCESS_RESPONSE, + impersonation_status=http_client.OK, + impersonation_data={ + "accessToken": "SA_ACCESS_TOKEN", + "expireTime": "2025-01-01T00:00:00Z", + }, + ) + credentials = self.make_credentials( + service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL + ) + + with mock.patch.object( + credentials, "_refresh_trust_boundary", autospec=True + ) as mock_refresh_trust_boundary: + credentials.refresh(request) + + mock_refresh_trust_boundary.assert_not_called() + + def test_refresh_fetches_no_op_trust_boundary(self): + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + credentials = self.make_credentials() + + with mock.patch.object( + credentials, + "_lookup_trust_boundary", + return_value=self.NO_OP_TRUST_BOUNDARY, + ) as mock_lookup, mock.patch.dict( + os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + ): + credentials.refresh(request) + + mock_lookup.assert_called_once() + headers = {} + credentials.apply(headers) + assert headers["x-allowed-locations"] == "" + + def test_refresh_skips_lookup_with_cached_no_op_boundary(self): + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + credentials = self.make_credentials() + credentials._trust_boundary = self.NO_OP_TRUST_BOUNDARY + + with mock.patch.object( + credentials, "_lookup_trust_boundary" + ) as mock_lookup, mock.patch.dict( + os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + ): + credentials.refresh(request) + + mock_lookup.assert_not_called() + headers = {} + credentials.apply(headers) + assert headers["x-allowed-locations"] == "" + + def test_refresh_fails_on_lookup_failure_with_no_cache(self): + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + credentials = self.make_credentials() + + with mock.patch.object( + credentials, + "_lookup_trust_boundary", + side_effect=exceptions.RefreshError("Lookup failed"), + ) as mock_lookup, mock.patch.dict( + os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + ), pytest.raises( + exceptions.RefreshError, match="Lookup failed" + ): + credentials.refresh(request) + + mock_lookup.assert_called_once() + + def test_refresh_uses_cached_boundary_on_lookup_failure(self): + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + credentials = self.make_credentials() + credentials._trust_boundary = {"encodedLocations": "0x123"} + + with mock.patch.object( + credentials, + "_lookup_trust_boundary", + side_effect=exceptions.RefreshError("Lookup failed"), + ) as mock_lookup, mock.patch.dict( + os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + ): + credentials.refresh(request) + + mock_lookup.assert_called_once() + headers = {} + credentials.apply(headers) + assert headers["x-allowed-locations"] == "0x123" + + def test_refresh_propagates_trust_boundary_to_impersonated_credential(self): + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + credentials = self.make_credentials( + service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, + trust_boundary=self.VALID_TRUST_BOUNDARY, + ) + impersonated_creds_mock = mock.Mock() + impersonated_creds_mock._trust_boundary = self.VALID_TRUST_BOUNDARY + + with mock.patch( + "google.auth.external_account.impersonated_credentials.Credentials", + return_value=impersonated_creds_mock, + ) as mock_impersonated_creds, mock.patch.dict( + os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + ): + credentials.refresh(request) + + mock_impersonated_creds.assert_called_once_with( + source_credentials=mock.ANY, + target_principal=mock.ANY, + target_scopes=mock.ANY, + quota_project_id=mock.ANY, + iam_endpoint_override=mock.ANY, + lifetime=mock.ANY, + trust_boundary=self.VALID_TRUST_BOUNDARY, + ) + assert credentials._trust_boundary == self.VALID_TRUST_BOUNDARY + + def test_build_trust_boundary_lookup_url_workload(self): + credentials = self.make_credentials() + expected_url = "https://iamcredentials.googleapis.com/v1/projects/123456/locations/global/workloadIdentityPools/POOL_ID/allowedLocations" + assert credentials._build_trust_boundary_lookup_url() == expected_url + + def test_build_trust_boundary_lookup_url_workforce(self): + credentials = self.make_workforce_pool_credentials() + expected_url = "https://iamcredentials.googleapis.com/v1/locations/global/workforcePools/POOL_ID/allowedLocations" + assert credentials._build_trust_boundary_lookup_url() == expected_url + + @pytest.mark.parametrize( + "audience", + [ + "invalid", + "//iam.googleapis.com/projects/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID", + "//iam.googleapis.com/locations/global/workforcsePools//providers/provider-id", + ], + ) + def test_build_trust_boundary_lookup_url_invalid_audience(self, audience): + credentials = self.make_credentials() + credentials._audience = audience + with pytest.raises(exceptions.InvalidValue, match="Invalid audience format."): + credentials._build_trust_boundary_lookup_url() + + def test_refresh_fetches_trust_boundary_workload(self): + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + credentials = self.make_credentials() + + with mock.patch.object( + credentials, + "_lookup_trust_boundary", + return_value=self.VALID_TRUST_BOUNDARY, + ) as mock_lookup, mock.patch.dict( + os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + ): + credentials.refresh(request) + + mock_lookup.assert_called_once() + headers = {} + credentials.apply(headers) + assert ( + headers["x-allowed-locations"] + == self.VALID_TRUST_BOUNDARY["encodedLocations"] + ) + + def test_refresh_fetches_trust_boundary_workforce(self): + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + credentials = self.make_workforce_pool_credentials() + + with mock.patch.object( + credentials, + "_lookup_trust_boundary", + return_value=self.VALID_TRUST_BOUNDARY, + ) as mock_lookup, mock.patch.dict( + os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + ): + credentials.refresh(request) + + mock_lookup.assert_called_once() + headers = {} + credentials.apply(headers) + assert ( + headers["x-allowed-locations"] + == self.VALID_TRUST_BOUNDARY["encodedLocations"] + ) + + @mock.patch( + "google.auth.metrics.python_and_auth_lib_version", + return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, + ) + @mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) + @mock.patch( + "google.auth.external_account.Credentials._mtls_required", return_value=True + ) + @mock.patch( + "google.auth.external_account.Credentials._get_mtls_cert_and_key_paths", + return_value=("path/to/cert.pem", "path/to/key.pem"), + ) + def test_refresh_with_mtls( + self, + mock_get_mtls_cert_and_key_paths, + mock_mtls_required, + unused_utcnow, + mock_auth_lib_value, + ): + response = self.SUCCESS_RESPONSE.copy() + # Test custom expiration to confirm expiry is set correctly. + response["expires_in"] = 2800 + expected_expiry = datetime.datetime.min + datetime.timedelta( + seconds=response["expires_in"] + ) + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "x-goog-api-client": "gl-python/3.7 auth/1.1 google-byoid-sdk sa-impersonation/false config-lifetime/false", + } + request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": self.AUDIENCE, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "subject_token": "subject_token_0", + "subject_token_type": self.SUBJECT_TOKEN_TYPE, + } + request = self.make_mock_request(status=http_client.OK, data=response) + credentials = self.make_credentials() + + credentials.refresh(request) + + expected_cert_path = ("path/to/cert.pem", "path/to/key.pem") + self.assert_token_request_kwargs( + request.call_args[1], headers, request_data, expected_cert_path + ) + assert credentials.valid + assert credentials.expiry == expected_expiry + assert not credentials.expired + assert credentials.token == response["access_token"] + + @mock.patch( + "google.auth.metrics.python_and_auth_lib_version", + return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, + ) + @mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) + def test_refresh_workforce_without_client_auth_success( + self, unused_utcnow, test_auth_lib_value + ): + response = self.SUCCESS_RESPONSE.copy() + # Test custom expiration to confirm expiry is set correctly. + response["expires_in"] = 2800 + expected_expiry = datetime.datetime.min + datetime.timedelta( + seconds=response["expires_in"] + ) + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "x-goog-api-client": "gl-python/3.7 auth/1.1 google-byoid-sdk sa-impersonation/false config-lifetime/false", + } + request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": self.WORKFORCE_AUDIENCE, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "subject_token": "subject_token_0", + "subject_token_type": self.WORKFORCE_SUBJECT_TOKEN_TYPE, + "options": urllib.parse.quote( + json.dumps({"userProject": self.WORKFORCE_POOL_USER_PROJECT}) + ), + } + request = self.make_mock_request(status=http_client.OK, data=response) + credentials = self.make_workforce_pool_credentials( + workforce_pool_user_project=self.WORKFORCE_POOL_USER_PROJECT + ) + + credentials.refresh(request) + + self.assert_token_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"] + + @mock.patch( + "google.auth.metrics.python_and_auth_lib_version", + return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, + ) + @mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) + def test_refresh_workforce_with_client_auth_success( + self, unused_utcnow, mock_auth_lib_value + ): + response = self.SUCCESS_RESPONSE.copy() + # Test custom expiration to confirm expiry is set correctly. + response["expires_in"] = 2800 + expected_expiry = datetime.datetime.min + datetime.timedelta( + seconds=response["expires_in"] + ) + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Basic {}".format(BASIC_AUTH_ENCODING), + "x-goog-api-client": "gl-python/3.7 auth/1.1 google-byoid-sdk sa-impersonation/false config-lifetime/false", + } + request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": self.WORKFORCE_AUDIENCE, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "subject_token": "subject_token_0", + "subject_token_type": self.WORKFORCE_SUBJECT_TOKEN_TYPE, + } + request = self.make_mock_request(status=http_client.OK, data=response) + # Client Auth will have higher priority over workforce_pool_user_project. + credentials = self.make_workforce_pool_credentials( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + workforce_pool_user_project=self.WORKFORCE_POOL_USER_PROJECT, + ) + + credentials.refresh(request) + + self.assert_token_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"] + + @mock.patch( + "google.auth.metrics.python_and_auth_lib_version", + return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, + ) + @mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) + def test_refresh_workforce_with_client_auth_and_no_workforce_project_success( + self, unused_utcnow, mock_lib_version_value + ): + response = self.SUCCESS_RESPONSE.copy() + # Test custom expiration to confirm expiry is set correctly. + response["expires_in"] = 2800 + expected_expiry = datetime.datetime.min + datetime.timedelta( + seconds=response["expires_in"] + ) + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Basic {}".format(BASIC_AUTH_ENCODING), + "x-goog-api-client": "gl-python/3.7 auth/1.1 google-byoid-sdk sa-impersonation/false config-lifetime/false", + } + request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": self.WORKFORCE_AUDIENCE, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "subject_token": "subject_token_0", + "subject_token_type": self.WORKFORCE_SUBJECT_TOKEN_TYPE, + } + request = self.make_mock_request(status=http_client.OK, data=response) + # Client Auth will be sufficient for user project determination. + credentials = self.make_workforce_pool_credentials( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + workforce_pool_user_project=None, + ) + + credentials.refresh(request) + + self.assert_token_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"] + + @mock.patch( + "google.auth.metrics.token_request_access_token_impersonate", + return_value=IMPERSONATE_ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, + ) + @mock.patch( + "google.auth.metrics.python_and_auth_lib_version", + return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, + ) + def test_refresh_impersonation_without_client_auth_success( + self, mock_metrics_header_value, mock_auth_lib_value + ): + # Simulate service account access token expires in 2800 seconds. + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=2800) + ).isoformat("T") + "Z" + expected_expiry = datetime.datetime.strptime(expire_time, "%Y-%m-%dT%H:%M:%SZ") + # STS token exchange request/response. + token_response = self.SUCCESS_RESPONSE.copy() + token_headers = { + "Content-Type": "application/x-www-form-urlencoded", + "x-goog-api-client": "gl-python/3.7 auth/1.1 google-byoid-sdk sa-impersonation/true config-lifetime/false", + } + token_request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": self.AUDIENCE, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "subject_token": "subject_token_0", + "subject_token_type": self.SUBJECT_TOKEN_TYPE, + "scope": "https://www.googleapis.com/auth/iam", + } + # Service account impersonation request/response. + impersonation_response = { + "accessToken": "SA_ACCESS_TOKEN", + "expireTime": expire_time, + } + impersonation_headers = { + "Content-Type": "application/json", + "authorization": "Bearer {}".format(token_response["access_token"]), + "x-goog-api-client": IMPERSONATE_ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, + } + impersonation_request_data = { + "delegates": None, + "scope": self.SCOPES, + "lifetime": "3600s", + } + # Initialize mock request to handle token exchange and service account + # impersonation request. + request = self.make_mock_request( + status=http_client.OK, + data=token_response, + impersonation_status=http_client.OK, + impersonation_data=impersonation_response, + ) + # Initialize credentials with service account impersonation. + credentials = self.make_credentials( + service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, + scopes=self.SCOPES, + ) + + credentials.refresh(request) + + # Only 2 requests should be processed. + assert len(request.call_args_list) == 2 + # Verify token exchange request parameters. + self.assert_token_request_kwargs( + request.call_args_list[0][1], token_headers, token_request_data + ) + # Verify service account impersonation request parameters. + self.assert_impersonation_request_kwargs( + request.call_args_list[1][1], + impersonation_headers, + impersonation_request_data, + ) + assert credentials.valid + assert credentials.expiry == expected_expiry + assert not credentials.expired + assert credentials.token == impersonation_response["accessToken"] + + @mock.patch( + "google.auth.metrics.token_request_access_token_impersonate", + return_value=IMPERSONATE_ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, + ) + @mock.patch( + "google.auth.metrics.python_and_auth_lib_version", + return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, + ) + @mock.patch( + "google.auth.external_account.Credentials._mtls_required", return_value=True + ) + @mock.patch( + "google.auth.external_account.Credentials._get_mtls_cert_and_key_paths", + return_value=("path/to/cert.pem", "path/to/key.pem"), + ) + def test_refresh_impersonation_with_mtls_success( + self, + mock_get_mtls_cert_and_key_paths, + mock_mtls_required, + mock_metrics_header_value, + mock_auth_lib_value, + ): + # Simulate service account access token expires in 2800 seconds. + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=2800) + ).isoformat("T") + "Z" + expected_expiry = datetime.datetime.strptime(expire_time, "%Y-%m-%dT%H:%M:%SZ") + # STS token exchange request/response. + token_response = self.SUCCESS_RESPONSE.copy() + token_headers = { + "Content-Type": "application/x-www-form-urlencoded", + "x-goog-api-client": "gl-python/3.7 auth/1.1 google-byoid-sdk sa-impersonation/true config-lifetime/false", + } + token_request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": self.AUDIENCE, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "subject_token": "subject_token_0", + "subject_token_type": self.SUBJECT_TOKEN_TYPE, + "scope": "https://www.googleapis.com/auth/iam", + } + # Service account impersonation request/response. + impersonation_response = { + "accessToken": "SA_ACCESS_TOKEN", + "expireTime": expire_time, + } + impersonation_headers = { + "Content-Type": "application/json", + "authorization": "Bearer {}".format(token_response["access_token"]), + "x-goog-api-client": IMPERSONATE_ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, + } + impersonation_request_data = { + "delegates": None, + "scope": self.SCOPES, + "lifetime": "3600s", + } + # Initialize mock request to handle token exchange and service account + # impersonation request. + request = self.make_mock_request( + status=http_client.OK, + data=token_response, + impersonation_status=http_client.OK, + impersonation_data=impersonation_response, + ) + # Initialize credentials with service account impersonation. + credentials = self.make_credentials( + service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, + scopes=self.SCOPES, + ) + + credentials.refresh(request) + + # Only 2 requests should be processed. + assert len(request.call_args_list) == 2 + # Verify token exchange request parameters. + expected_cert_paths = ("path/to/cert.pem", "path/to/key.pem") + self.assert_token_request_kwargs( + request.call_args_list[0][1], + token_headers, + token_request_data, + expected_cert_paths, + ) + # Verify service account impersonation request parameters. + self.assert_impersonation_request_kwargs( + request.call_args_list[1][1], + impersonation_headers, + impersonation_request_data, + expected_cert_paths, + ) + assert credentials.valid + assert credentials.expiry == expected_expiry + assert not credentials.expired + assert credentials.token == impersonation_response["accessToken"] + + @mock.patch( + "google.auth.metrics.token_request_access_token_impersonate", + return_value=IMPERSONATE_ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, + ) + @mock.patch( + "google.auth.metrics.python_and_auth_lib_version", + return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, + ) + def test_refresh_workforce_impersonation_without_client_auth_success( + self, mock_metrics_header_value, mock_auth_lib_value + ): + # Simulate service account access token expires in 2800 seconds. + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=2800) + ).isoformat("T") + "Z" + expected_expiry = datetime.datetime.strptime(expire_time, "%Y-%m-%dT%H:%M:%SZ") + # STS token exchange request/response. + token_response = self.SUCCESS_RESPONSE.copy() + token_headers = { + "Content-Type": "application/x-www-form-urlencoded", + "x-goog-api-client": "gl-python/3.7 auth/1.1 google-byoid-sdk sa-impersonation/true config-lifetime/false", + } + token_request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": self.WORKFORCE_AUDIENCE, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "subject_token": "subject_token_0", + "subject_token_type": self.WORKFORCE_SUBJECT_TOKEN_TYPE, + "scope": "https://www.googleapis.com/auth/iam", + "options": urllib.parse.quote( + json.dumps({"userProject": self.WORKFORCE_POOL_USER_PROJECT}) + ), + } + # Service account impersonation request/response. + impersonation_response = { + "accessToken": "SA_ACCESS_TOKEN", + "expireTime": expire_time, + } + impersonation_headers = { + "Content-Type": "application/json", + "authorization": "Bearer {}".format(token_response["access_token"]), + "x-goog-api-client": IMPERSONATE_ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, + } + impersonation_request_data = { + "delegates": None, + "scope": self.SCOPES, + "lifetime": "3600s", + } + # Initialize mock request to handle token exchange and service account + # impersonation request. + request = self.make_mock_request( + status=http_client.OK, + data=token_response, + impersonation_status=http_client.OK, + impersonation_data=impersonation_response, + ) + # Initialize credentials with service account impersonation. + credentials = self.make_workforce_pool_credentials( + service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, + scopes=self.SCOPES, + workforce_pool_user_project=self.WORKFORCE_POOL_USER_PROJECT, + ) + + credentials.refresh(request) + + # Only 2 requests should be processed. + assert len(request.call_args_list) == 2 + # Verify token exchange request parameters. + self.assert_token_request_kwargs( + request.call_args_list[0][1], token_headers, token_request_data + ) + # Verify service account impersonation request parameters. + self.assert_impersonation_request_kwargs( + request.call_args_list[1][1], + impersonation_headers, + impersonation_request_data, + ) + assert credentials.valid + assert credentials.expiry == expected_expiry + assert not credentials.expired + assert credentials.token == impersonation_response["accessToken"] + + @mock.patch( + "google.auth.metrics.python_and_auth_lib_version", + return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, + ) + def test_refresh_without_client_auth_success_explicit_user_scopes_ignore_default_scopes( + self, mock_auth_lib_value + ): + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "x-goog-api-client": "gl-python/3.7 auth/1.1 google-byoid-sdk sa-impersonation/false config-lifetime/false", + } + request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": self.AUDIENCE, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "scope": "scope1 scope2", + "subject_token": "subject_token_0", + "subject_token_type": self.SUBJECT_TOKEN_TYPE, + } + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + credentials = self.make_credentials( + scopes=["scope1", "scope2"], + # Default scopes will be ignored in favor of user scopes. + default_scopes=["ignored"], + ) + + credentials.refresh(request) + + self.assert_token_request_kwargs(request.call_args[1], headers, request_data) + assert credentials.valid + assert not credentials.expired + assert credentials.token == self.SUCCESS_RESPONSE["access_token"] + assert credentials.has_scopes(["scope1", "scope2"]) + assert not credentials.has_scopes(["ignored"]) + + @mock.patch( + "google.auth.metrics.python_and_auth_lib_version", + return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, + ) + def test_refresh_without_client_auth_success_explicit_default_scopes_only( + self, mock_auth_lib_value + ): + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "x-goog-api-client": "gl-python/3.7 auth/1.1 google-byoid-sdk sa-impersonation/false config-lifetime/false", + } + request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": self.AUDIENCE, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "scope": "scope1 scope2", + "subject_token": "subject_token_0", + "subject_token_type": self.SUBJECT_TOKEN_TYPE, + } + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + credentials = self.make_credentials( + scopes=None, + # Default scopes will be used since user scopes are none. + default_scopes=["scope1", "scope2"], + ) + + credentials.refresh(request) + + self.assert_token_request_kwargs(request.call_args[1], headers, request_data) + assert credentials.valid + assert not credentials.expired + assert credentials.token == self.SUCCESS_RESPONSE["access_token"] + assert credentials.has_scopes(["scope1", "scope2"]) + + def test_refresh_without_client_auth_error(self): + request = self.make_mock_request( + status=http_client.BAD_REQUEST, data=self.ERROR_RESPONSE + ) + credentials = self.make_credentials() + + with pytest.raises(exceptions.OAuthError) as excinfo: + credentials.refresh(request) + + assert excinfo.match( + r"Error code invalid_request: Invalid subject token - https://tools.ietf.org/html/rfc6749" + ) + assert not credentials.expired + assert credentials.token is None + + def test_refresh_impersonation_without_client_auth_error(self): + request = self.make_mock_request( + status=http_client.OK, + data=self.SUCCESS_RESPONSE, + impersonation_status=http_client.BAD_REQUEST, + impersonation_data=self.IMPERSONATION_ERROR_RESPONSE, + ) + credentials = self.make_credentials( + service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, + scopes=self.SCOPES, + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.refresh(request) + + assert excinfo.match(r"Unable to acquire impersonated credentials") + assert not credentials.expired + assert credentials.token is None + + def test_refresh_impersonation_invalid_impersonated_url_error(self): + credentials = self.make_credentials( + service_account_impersonation_url="https://iamcredentials.googleapis.com/v1/invalid", + scopes=self.SCOPES, + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.refresh(None) + + assert excinfo.match( + r"Unable to determine target principal from service account impersonation URL." + ) + assert not credentials.expired + assert credentials.token is None + + def test_refresh_impersonation_invalid_url_format_error(self): + credentials = self.make_credentials( + service_account_impersonation_url="https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/:generateAccessToken/invalid", + scopes=self.SCOPES, + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.refresh(None) + + assert excinfo.match( + r"Unable to determine target principal from service account impersonation URL." + ) + assert not credentials.expired + assert credentials.token is None + + @mock.patch( + "google.auth.metrics.python_and_auth_lib_version", + return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, + ) + def test_refresh_with_client_auth_success(self, mock_auth_lib_value): + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Basic {}".format(BASIC_AUTH_ENCODING), + "x-goog-api-client": "gl-python/3.7 auth/1.1 google-byoid-sdk sa-impersonation/false config-lifetime/false", + } + request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": self.AUDIENCE, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "subject_token": "subject_token_0", + "subject_token_type": self.SUBJECT_TOKEN_TYPE, + } + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + credentials = self.make_credentials( + client_id=CLIENT_ID, client_secret=CLIENT_SECRET + ) + + credentials.refresh(request) + + self.assert_token_request_kwargs(request.call_args[1], headers, request_data) + assert credentials.valid + assert not credentials.expired + assert credentials.token == self.SUCCESS_RESPONSE["access_token"] + + @mock.patch( + "google.auth.metrics.token_request_access_token_impersonate", + return_value=IMPERSONATE_ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, + ) + @mock.patch( + "google.auth.metrics.python_and_auth_lib_version", + return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, + ) + def test_refresh_impersonation_with_client_auth_success_ignore_default_scopes( + self, mock_metrics_header_value, mock_auth_lib_value + ): + # Simulate service account access token expires in 2800 seconds. + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=2800) + ).isoformat("T") + "Z" + expected_expiry = datetime.datetime.strptime(expire_time, "%Y-%m-%dT%H:%M:%SZ") + # STS token exchange request/response. + token_response = self.SUCCESS_RESPONSE.copy() + token_headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Basic {}".format(BASIC_AUTH_ENCODING), + "x-goog-api-client": "gl-python/3.7 auth/1.1 google-byoid-sdk sa-impersonation/true config-lifetime/false", + } + token_request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": self.AUDIENCE, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "subject_token": "subject_token_0", + "subject_token_type": self.SUBJECT_TOKEN_TYPE, + "scope": "https://www.googleapis.com/auth/iam", + } + # Service account impersonation request/response. + impersonation_response = { + "accessToken": "SA_ACCESS_TOKEN", + "expireTime": expire_time, + } + impersonation_headers = { + "Content-Type": "application/json", + "authorization": "Bearer {}".format(token_response["access_token"]), + "x-goog-api-client": IMPERSONATE_ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, + } + impersonation_request_data = { + "delegates": None, + "scope": self.SCOPES, + "lifetime": "3600s", + } + # Initialize mock request to handle token exchange and service account + # impersonation request. + request = self.make_mock_request( + status=http_client.OK, + data=token_response, + impersonation_status=http_client.OK, + impersonation_data=impersonation_response, + ) + # Initialize credentials with service account impersonation and basic auth. + credentials = self.make_credentials( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, + scopes=self.SCOPES, + # Default scopes will be ignored since user scopes are specified. + default_scopes=["ignored"], + ) + + credentials.refresh(request) + + # Only 2 requests should be processed. + assert len(request.call_args_list) == 2 + # Verify token exchange request parameters. + self.assert_token_request_kwargs( + request.call_args_list[0][1], token_headers, token_request_data + ) + # Verify service account impersonation request parameters. + self.assert_impersonation_request_kwargs( + request.call_args_list[1][1], + impersonation_headers, + impersonation_request_data, + ) + assert credentials.valid + assert credentials.expiry == expected_expiry + assert not credentials.expired + assert credentials.token == impersonation_response["accessToken"] + + @mock.patch( + "google.auth.metrics.token_request_access_token_impersonate", + return_value=IMPERSONATE_ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, + ) + @mock.patch( + "google.auth.metrics.python_and_auth_lib_version", + return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, + ) + def test_refresh_impersonation_with_client_auth_success_use_default_scopes( + self, mock_metrics_header_value, mock_auth_lib_value + ): + # Simulate service account access token expires in 2800 seconds. + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=2800) + ).isoformat("T") + "Z" + expected_expiry = datetime.datetime.strptime(expire_time, "%Y-%m-%dT%H:%M:%SZ") + # STS token exchange request/response. + token_response = self.SUCCESS_RESPONSE.copy() + token_headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Basic {}".format(BASIC_AUTH_ENCODING), + "x-goog-api-client": "gl-python/3.7 auth/1.1 google-byoid-sdk sa-impersonation/true config-lifetime/false", + } + token_request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": self.AUDIENCE, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "subject_token": "subject_token_0", + "subject_token_type": self.SUBJECT_TOKEN_TYPE, + "scope": "https://www.googleapis.com/auth/iam", + } + # Service account impersonation request/response. + impersonation_response = { + "accessToken": "SA_ACCESS_TOKEN", + "expireTime": expire_time, + } + impersonation_headers = { + "Content-Type": "application/json", + "authorization": "Bearer {}".format(token_response["access_token"]), + "x-goog-api-client": IMPERSONATE_ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, + } + impersonation_request_data = { + "delegates": None, + "scope": self.SCOPES, + "lifetime": "3600s", + } + # Initialize mock request to handle token exchange and service account + # impersonation request. + request = self.make_mock_request( + status=http_client.OK, + data=token_response, + impersonation_status=http_client.OK, + impersonation_data=impersonation_response, + ) + # Initialize credentials with service account impersonation and basic auth. + credentials = self.make_credentials( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, + scopes=None, + # Default scopes will be used since user specified scopes are none. + default_scopes=self.SCOPES, + ) + + credentials.refresh(request) + + # Only 2 requests should be processed. + assert len(request.call_args_list) == 2 + # Verify token exchange request parameters. + self.assert_token_request_kwargs( + request.call_args_list[0][1], token_headers, token_request_data + ) + # Verify service account impersonation request parameters. + self.assert_impersonation_request_kwargs( + request.call_args_list[1][1], + impersonation_headers, + impersonation_request_data, + ) + assert credentials.valid + assert credentials.expiry == expected_expiry + assert not credentials.expired + assert credentials.token == impersonation_response["accessToken"] + + def test_apply_without_quota_project_id(self): + headers = {} + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + credentials = self.make_credentials() + + credentials.refresh(request) + credentials.apply(headers) + + assert headers == { + "authorization": "Bearer {}".format(self.SUCCESS_RESPONSE["access_token"]) + } + + def test_apply_workforce_without_quota_project_id(self): + headers = {} + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + credentials = self.make_workforce_pool_credentials( + workforce_pool_user_project=self.WORKFORCE_POOL_USER_PROJECT + ) + + credentials.refresh(request) + credentials.apply(headers) + + assert headers == { + "authorization": "Bearer {}".format(self.SUCCESS_RESPONSE["access_token"]) + } + + def test_apply_impersonation_without_quota_project_id(self): + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=3600) + ).isoformat("T") + "Z" + # Service account impersonation response. + impersonation_response = { + "accessToken": "SA_ACCESS_TOKEN", + "expireTime": expire_time, + } + # Initialize mock request to handle token exchange and service account + # impersonation request. + request = self.make_mock_request( + status=http_client.OK, + data=self.SUCCESS_RESPONSE.copy(), + impersonation_status=http_client.OK, + impersonation_data=impersonation_response, + ) + # Initialize credentials with service account impersonation. + credentials = self.make_credentials( + service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, + scopes=self.SCOPES, + ) + headers = {} + + credentials.refresh(request) + credentials.apply(headers) + + assert headers == { + "authorization": "Bearer {}".format(impersonation_response["accessToken"]) + } + + def test_apply_with_quota_project_id(self): + headers = {"other": "header-value"} + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + credentials = self.make_credentials(quota_project_id=self.QUOTA_PROJECT_ID) + + credentials.refresh(request) + credentials.apply(headers) + + assert headers == { + "other": "header-value", + "authorization": "Bearer {}".format(self.SUCCESS_RESPONSE["access_token"]), + "x-goog-user-project": self.QUOTA_PROJECT_ID, + } + + def test_apply_impersonation_with_quota_project_id(self): + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=3600) + ).isoformat("T") + "Z" + # Service account impersonation response. + impersonation_response = { + "accessToken": "SA_ACCESS_TOKEN", + "expireTime": expire_time, + } + # Initialize mock request to handle token exchange and service account + # impersonation request. + request = self.make_mock_request( + status=http_client.OK, + data=self.SUCCESS_RESPONSE.copy(), + impersonation_status=http_client.OK, + impersonation_data=impersonation_response, + ) + # Initialize credentials with service account impersonation. + credentials = self.make_credentials( + service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, + scopes=self.SCOPES, + quota_project_id=self.QUOTA_PROJECT_ID, + ) + headers = {"other": "header-value"} + + credentials.refresh(request) + credentials.apply(headers) + + assert headers == { + "other": "header-value", + "authorization": "Bearer {}".format(impersonation_response["accessToken"]), + "x-goog-user-project": self.QUOTA_PROJECT_ID, + } + + def test_before_request(self): + headers = {"other": "header-value"} + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + credentials = self.make_credentials() + + # First call should call refresh, setting the token. + credentials.before_request(request, "POST", "https://example.com/api", headers) + + assert headers == { + "other": "header-value", + "authorization": "Bearer {}".format(self.SUCCESS_RESPONSE["access_token"]), + } + + # Second call shouldn't call refresh. + credentials.before_request(request, "POST", "https://example.com/api", headers) + + assert headers == { + "other": "header-value", + "authorization": "Bearer {}".format(self.SUCCESS_RESPONSE["access_token"]), + } + + def test_before_request_workforce(self): + headers = {"other": "header-value"} + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + credentials = self.make_workforce_pool_credentials( + workforce_pool_user_project=self.WORKFORCE_POOL_USER_PROJECT + ) + + # First call should call refresh, setting the token. + credentials.before_request(request, "POST", "https://example.com/api", headers) + + assert headers == { + "other": "header-value", + "authorization": "Bearer {}".format(self.SUCCESS_RESPONSE["access_token"]), + } + + # Second call shouldn't call refresh. + credentials.before_request(request, "POST", "https://example.com/api", headers) + + assert headers == { + "other": "header-value", + "authorization": "Bearer {}".format(self.SUCCESS_RESPONSE["access_token"]), + } + + def test_before_request_impersonation(self): + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=3600) + ).isoformat("T") + "Z" + # Service account impersonation response. + impersonation_response = { + "accessToken": "SA_ACCESS_TOKEN", + "expireTime": expire_time, + } + # Initialize mock request to handle token exchange and service account + # impersonation request. + request = self.make_mock_request( + status=http_client.OK, + data=self.SUCCESS_RESPONSE.copy(), + impersonation_status=http_client.OK, + impersonation_data=impersonation_response, + ) + headers = {"other": "header-value"} + credentials = self.make_credentials( + service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL + ) + + # First call should call refresh, setting the token. + credentials.before_request(request, "POST", "https://example.com/api", headers) + + assert headers == { + "other": "header-value", + "authorization": "Bearer {}".format(impersonation_response["accessToken"]), + } + + # Second call shouldn't call refresh. + credentials.before_request(request, "POST", "https://example.com/api", headers) + + assert headers == { + "other": "header-value", + "authorization": "Bearer {}".format(impersonation_response["accessToken"]), + } + + @mock.patch("google.auth._helpers.utcnow") + def test_before_request_expired(self, utcnow): + headers = {} + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + credentials = self.make_credentials() + credentials.token = "token" + utcnow.return_value = datetime.datetime.min + # Set the expiration to one second more than now plus the clock skew + # accomodation. These credentials should be valid. + credentials.expiry = ( + datetime.datetime.min + + _helpers.REFRESH_THRESHOLD + + datetime.timedelta(seconds=1) + ) + + assert credentials.valid + assert not credentials.expired + assert credentials.token_state == TokenState.FRESH + + credentials.before_request(request, "POST", "https://example.com/api", headers) + + # Cached token should be used. + assert headers == {"authorization": "Bearer token"} + + # Next call should simulate 1 second passed. + utcnow.return_value = datetime.datetime.min + datetime.timedelta(seconds=1) + + assert not credentials.valid + assert credentials.expired + assert credentials.token_state == TokenState.STALE + + credentials.before_request(request, "POST", "https://example.com/api", headers) + assert credentials.token_state == TokenState.FRESH + + # New token should be retrieved. + assert headers == { + "authorization": "Bearer {}".format(self.SUCCESS_RESPONSE["access_token"]) + } + + def test_refresh_impersonation_trust_boundary(self): + request = self.make_mock_request( + status=http_client.OK, + data=self.SUCCESS_RESPONSE, + impersonation_status=http_client.OK, + impersonation_data={ + "accessToken": "SA_ACCESS_TOKEN", + "expireTime": "2025-01-01T00:00:00Z", + }, + ) + credentials = self.make_credentials( + service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL + ) + impersonated_creds_mock = mock.Mock() + impersonated_creds_mock._trust_boundary = self.VALID_TRUST_BOUNDARY + + with mock.patch( + "google.auth.external_account.impersonated_credentials.Credentials", + return_value=impersonated_creds_mock, + ): + credentials.refresh(request) + + assert credentials._trust_boundary == self.VALID_TRUST_BOUNDARY + + def test_with_trust_boundary(self): + credentials = self.make_credentials() + new_credentials = credentials.with_trust_boundary(self.VALID_TRUST_BOUNDARY) + assert new_credentials._trust_boundary == self.VALID_TRUST_BOUNDARY + + @mock.patch("google.auth._helpers.utcnow") + def test_before_request_impersonation_expired(self, utcnow): + headers = {} + expire_time = ( + datetime.datetime.min + datetime.timedelta(seconds=3601) + ).isoformat("T") + "Z" + # Service account impersonation response. + impersonation_response = { + "accessToken": "SA_ACCESS_TOKEN", + "expireTime": expire_time, + } + # Initialize mock request to handle token exchange and service account + # impersonation request. + request = self.make_mock_request( + status=http_client.OK, + data=self.SUCCESS_RESPONSE.copy(), + impersonation_status=http_client.OK, + impersonation_data=impersonation_response, + ) + credentials = self.make_credentials( + service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL + ) + credentials.token = "token" + utcnow.return_value = datetime.datetime.min + # Set the expiration to one second more than now plus the clock skew + # accomodation. These credentials should be valid. + credentials.expiry = ( + datetime.datetime.min + + _helpers.REFRESH_THRESHOLD + + datetime.timedelta(seconds=1) + ) + + assert credentials.valid + assert not credentials.expired + assert credentials.token_state == TokenState.FRESH + + credentials.before_request(request, "POST", "https://example.com/api", headers) + assert credentials.token_state == TokenState.FRESH + + # Cached token should be used. + assert headers == {"authorization": "Bearer token"} + + # Next call should simulate 1 second passed. This will trigger the expiration + # threshold. + utcnow.return_value = datetime.datetime.min + datetime.timedelta(seconds=1) + + assert not credentials.valid + assert credentials.expired + assert credentials.token_state == TokenState.STALE + + credentials.before_request(request, "POST", "https://example.com/api", headers) + assert credentials.token_state == TokenState.FRESH + + credentials.before_request(request, "POST", "https://example.com/api", headers) + + # New token should be retrieved. + assert headers == { + "authorization": "Bearer {}".format(impersonation_response["accessToken"]) + } + + @pytest.mark.parametrize( + "audience", + [ + # Legacy K8s audience format. + "identitynamespace:1f12345:my_provider", + # Unrealistic audiences. + "//iam.googleapis.com/projects", + "//iam.googleapis.com/projects/", + "//iam.googleapis.com/project/123456", + "//iam.googleapis.com/projects//123456", + "//iam.googleapis.com/prefix_projects/123456", + "//iam.googleapis.com/projects_suffix/123456", + ], + ) + def test_project_number_indeterminable(self, audience): + credentials = CredentialsImpl( + audience=audience, + subject_token_type=self.SUBJECT_TOKEN_TYPE, + token_url=self.TOKEN_URL, + credential_source=self.CREDENTIAL_SOURCE, + ) + + assert credentials.project_number is None + assert credentials.get_project_id(None) is None + + def test_project_number_determinable(self): + credentials = CredentialsImpl( + audience=self.AUDIENCE, + subject_token_type=self.SUBJECT_TOKEN_TYPE, + token_url=self.TOKEN_URL, + credential_source=self.CREDENTIAL_SOURCE, + ) + + assert credentials.project_number == self.PROJECT_NUMBER + + def test_project_number_workforce(self): + credentials = CredentialsImpl( + audience=self.WORKFORCE_AUDIENCE, + subject_token_type=self.WORKFORCE_SUBJECT_TOKEN_TYPE, + token_url=self.TOKEN_URL, + credential_source=self.CREDENTIAL_SOURCE, + workforce_pool_user_project=self.WORKFORCE_POOL_USER_PROJECT, + ) + + assert credentials.project_number is None + + def test_project_id_without_scopes(self): + # Initialize credentials with no scopes. + credentials = CredentialsImpl( + audience=self.AUDIENCE, + subject_token_type=self.SUBJECT_TOKEN_TYPE, + token_url=self.TOKEN_URL, + credential_source=self.CREDENTIAL_SOURCE, + ) + + assert credentials.get_project_id(None) is None + + @mock.patch( + "google.auth.metrics.token_request_access_token_impersonate", + return_value=IMPERSONATE_ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, + ) + @mock.patch( + "google.auth.metrics.python_and_auth_lib_version", + return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, + ) + def test_get_project_id_cloud_resource_manager_success( + self, mock_metrics_header_value, mock_auth_lib_value + ): + # STS token exchange request/response. + token_response = self.SUCCESS_RESPONSE.copy() + token_headers = { + "Content-Type": "application/x-www-form-urlencoded", + "x-goog-api-client": "gl-python/3.7 auth/1.1 google-byoid-sdk sa-impersonation/true config-lifetime/false", + } + token_request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": self.AUDIENCE, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "subject_token": "subject_token_0", + "subject_token_type": self.SUBJECT_TOKEN_TYPE, + "scope": "https://www.googleapis.com/auth/iam", + } + # Service account impersonation request/response. + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=3600) + ).isoformat("T") + "Z" + expected_expiry = datetime.datetime.strptime(expire_time, "%Y-%m-%dT%H:%M:%SZ") + impersonation_response = { + "accessToken": "SA_ACCESS_TOKEN", + "expireTime": expire_time, + } + impersonation_headers = { + "Content-Type": "application/json", + "x-goog-user-project": self.QUOTA_PROJECT_ID, + "authorization": "Bearer {}".format(token_response["access_token"]), + "x-goog-api-client": IMPERSONATE_ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, + } + impersonation_request_data = { + "delegates": None, + "scope": self.SCOPES, + "lifetime": "3600s", + } + # Initialize mock request to handle token exchange, service account + # impersonation and cloud resource manager request. + request = self.make_mock_request( + status=http_client.OK, + data=self.SUCCESS_RESPONSE.copy(), + impersonation_status=http_client.OK, + impersonation_data=impersonation_response, + cloud_resource_manager_status=http_client.OK, + cloud_resource_manager_data=self.CLOUD_RESOURCE_MANAGER_SUCCESS_RESPONSE, + ) + credentials = self.make_credentials( + service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, + scopes=self.SCOPES, + quota_project_id=self.QUOTA_PROJECT_ID, + ) + + # Expected project ID from cloud resource manager response should be returned. + project_id = credentials.get_project_id(request) + + assert project_id == self.PROJECT_ID + # 3 requests should be processed. + assert len(request.call_args_list) == 3 + # Verify token exchange request parameters. + self.assert_token_request_kwargs( + request.call_args_list[0][1], token_headers, token_request_data + ) + # Verify service account impersonation request parameters. + self.assert_impersonation_request_kwargs( + request.call_args_list[1][1], + impersonation_headers, + impersonation_request_data, + ) + # In the process of getting project ID, an access token should be + # retrieved. + assert credentials.valid + assert credentials.expiry == expected_expiry + assert not credentials.expired + assert credentials.token == impersonation_response["accessToken"] + # Verify cloud resource manager request parameters. + self.assert_resource_manager_request_kwargs( + request.call_args_list[2][1], + self.PROJECT_NUMBER, + { + "x-goog-user-project": self.QUOTA_PROJECT_ID, + "authorization": "Bearer {}".format( + impersonation_response["accessToken"] + ), + }, + ) + + # Calling get_project_id again should return the cached project_id. + project_id = credentials.get_project_id(request) + + assert project_id == self.PROJECT_ID + # No additional requests. + assert len(request.call_args_list) == 3 + + @mock.patch( + "google.auth.metrics.python_and_auth_lib_version", + return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, + ) + def test_workforce_pool_get_project_id_cloud_resource_manager_success( + self, mock_auth_lib_value + ): + # STS token exchange request/response. + token_headers = { + "Content-Type": "application/x-www-form-urlencoded", + "x-goog-api-client": "gl-python/3.7 auth/1.1 google-byoid-sdk sa-impersonation/false config-lifetime/false", + } + token_request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": self.WORKFORCE_AUDIENCE, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "subject_token": "subject_token_0", + "subject_token_type": self.WORKFORCE_SUBJECT_TOKEN_TYPE, + "scope": "scope1 scope2", + "options": urllib.parse.quote( + json.dumps({"userProject": self.WORKFORCE_POOL_USER_PROJECT}) + ), + } + # Initialize mock request to handle token exchange and cloud resource + # manager request. + request = self.make_mock_request( + status=http_client.OK, + data=self.SUCCESS_RESPONSE.copy(), + cloud_resource_manager_status=http_client.OK, + cloud_resource_manager_data=self.CLOUD_RESOURCE_MANAGER_SUCCESS_RESPONSE, + ) + credentials = self.make_workforce_pool_credentials( + scopes=self.SCOPES, + quota_project_id=self.QUOTA_PROJECT_ID, + workforce_pool_user_project=self.WORKFORCE_POOL_USER_PROJECT, + ) + + # Expected project ID from cloud resource manager response should be returned. + project_id = credentials.get_project_id(request) + + assert project_id == self.PROJECT_ID + # 2 requests should be processed. + assert len(request.call_args_list) == 2 + # Verify token exchange request parameters. + self.assert_token_request_kwargs( + request.call_args_list[0][1], token_headers, token_request_data + ) + # In the process of getting project ID, an access token should be + # retrieved. + assert credentials.valid + assert not credentials.expired + assert credentials.token == self.SUCCESS_RESPONSE["access_token"] + # Verify cloud resource manager request parameters. + self.assert_resource_manager_request_kwargs( + request.call_args_list[1][1], + self.WORKFORCE_POOL_USER_PROJECT, + { + "x-goog-user-project": self.QUOTA_PROJECT_ID, + "authorization": "Bearer {}".format( + self.SUCCESS_RESPONSE["access_token"] + ), + }, + ) + + # Calling get_project_id again should return the cached project_id. + project_id = credentials.get_project_id(request) + + assert project_id == self.PROJECT_ID + # No additional requests. + assert len(request.call_args_list) == 2 + + @mock.patch( + "google.auth.metrics.token_request_access_token_impersonate", + return_value=IMPERSONATE_ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, + ) + @mock.patch( + "google.auth.metrics.python_and_auth_lib_version", + return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, + ) + def test_refresh_impersonation_with_lifetime( + self, mock_metrics_header_value, mock_auth_lib_value + ): + # Simulate service account access token expires in 2800 seconds. + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=2800) + ).isoformat("T") + "Z" + expected_expiry = datetime.datetime.strptime(expire_time, "%Y-%m-%dT%H:%M:%SZ") + # STS token exchange request/response. + token_response = self.SUCCESS_RESPONSE.copy() + token_headers = { + "Content-Type": "application/x-www-form-urlencoded", + "x-goog-api-client": "gl-python/3.7 auth/1.1 google-byoid-sdk sa-impersonation/true config-lifetime/true", + } + token_request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": self.AUDIENCE, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "subject_token": "subject_token_0", + "subject_token_type": self.SUBJECT_TOKEN_TYPE, + "scope": "https://www.googleapis.com/auth/iam", + } + # Service account impersonation request/response. + impersonation_response = { + "accessToken": "SA_ACCESS_TOKEN", + "expireTime": expire_time, + } + impersonation_headers = { + "Content-Type": "application/json", + "authorization": "Bearer {}".format(token_response["access_token"]), + "x-goog-api-client": IMPERSONATE_ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, + } + impersonation_request_data = { + "delegates": None, + "scope": self.SCOPES, + "lifetime": "2800s", + } + # Initialize mock request to handle token exchange and service account + # impersonation request. + request = self.make_mock_request( + status=http_client.OK, + data=token_response, + impersonation_status=http_client.OK, + impersonation_data=impersonation_response, + ) + # Initialize credentials with service account impersonation. + credentials = self.make_credentials( + service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, + service_account_impersonation_options={"token_lifetime_seconds": 2800}, + scopes=self.SCOPES, + ) + + credentials.refresh(request) + + # Only 2 requests should be processed. + assert len(request.call_args_list) == 2 + # Verify token exchange request parameters. + self.assert_token_request_kwargs( + request.call_args_list[0][1], token_headers, token_request_data + ) + # Verify service account impersonation request parameters. + self.assert_impersonation_request_kwargs( + request.call_args_list[1][1], + impersonation_headers, + impersonation_request_data, + ) + assert credentials.valid + assert credentials.expiry == expected_expiry + assert not credentials.expired + assert credentials.token == impersonation_response["accessToken"] + + def test_get_project_id_cloud_resource_manager_error(self): + # Simulate resource doesn't have sufficient permissions to access + # cloud resource manager. + request = self.make_mock_request( + status=http_client.OK, + data=self.SUCCESS_RESPONSE.copy(), + cloud_resource_manager_status=http_client.UNAUTHORIZED, + ) + credentials = self.make_credentials(scopes=self.SCOPES) + + project_id = credentials.get_project_id(request) + + assert project_id is None + # Only 2 requests to STS and cloud resource manager should be sent. + assert len(request.call_args_list) == 2 + + def test_refresh_with_existing_impersonated_credentials(self): + credentials = self.make_credentials( + service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL + ) + credentials._impersonated_credentials = mock.Mock() + request = self.make_mock_request() + + credentials.refresh(request) + + credentials._impersonated_credentials.refresh.assert_called_once_with(request) + + def test_get_mtls_cert_and_key_paths(self): + credentials = self.make_credentials() + with pytest.raises(NotImplementedError): + credentials._get_mtls_cert_and_key_paths() + + +def test_supplier_context(): + context = external_account.SupplierContext("TestTokenType", "TestAudience") + + assert context.subject_token_type == "TestTokenType" + assert context.audience == "TestAudience" diff --git a/packages/google-auth/tests/test_external_account_authorized_user.py b/packages/google-auth/tests/test_external_account_authorized_user.py new file mode 100644 index 000000000000..71bfaa39c9c8 --- /dev/null +++ b/packages/google-auth/tests/test_external_account_authorized_user.py @@ -0,0 +1,686 @@ +# 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. + +import datetime +import http.client as http_client +import json +import os + +import mock +import pytest # type: ignore + +from google.auth import environment_vars +from google.auth import exceptions +from google.auth import external_account_authorized_user +from google.auth import transport +from google.auth.credentials import DEFAULT_UNIVERSE_DOMAIN + +TOKEN_URL = "https://sts.googleapis.com/v1/token" +TOKEN_INFO_URL = "https://sts.googleapis.com/v1/introspect" +REVOKE_URL = "https://sts.googleapis.com/v1/revoke" +QUOTA_PROJECT_ID = "654321" +POOL_ID = "POOL_ID" +PROVIDER_ID = "PROVIDER_ID" +AUDIENCE = ( + "//iam.googleapis.com/locations/global/workforcePools/{}/providers/{}".format( + POOL_ID, PROVIDER_ID + ) +) +REFRESH_TOKEN = "REFRESH_TOKEN" +NEW_REFRESH_TOKEN = "NEW_REFRESH_TOKEN" +ACCESS_TOKEN = "ACCESS_TOKEN" +CLIENT_ID = "username" +CLIENT_SECRET = "password" +# Base64 encoding of "username:password". +BASIC_AUTH_ENCODING = "dXNlcm5hbWU6cGFzc3dvcmQ=" +SCOPES = ["email", "profile"] +NOW = datetime.datetime(1990, 8, 27, 6, 54, 30) +FAKE_UNIVERSE_DOMAIN = "fake-universe-domain" + + +class TestCredentials(object): + @classmethod + def make_credentials( + cls, + audience=AUDIENCE, + refresh_token=REFRESH_TOKEN, + token_url=TOKEN_URL, + token_info_url=TOKEN_INFO_URL, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + **kwargs + ): + return external_account_authorized_user.Credentials( + audience=audience, + refresh_token=refresh_token, + token_url=token_url, + token_info_url=token_info_url, + client_id=client_id, + client_secret=client_secret, + **kwargs + ) + + @classmethod + def make_mock_request(cls, status=http_client.OK, data=None): + # STS token exchange request. + token_response = mock.create_autospec(transport.Response, instance=True) + token_response.status = status + token_response.data = json.dumps(data).encode("utf-8") + responses = [token_response] + + request = mock.create_autospec(transport.Request) + request.side_effect = responses + + return request + + def test_get_cred_info(self): + credentials = self.make_credentials() + assert not credentials.get_cred_info() + + credentials._cred_file_path = "/path/to/file" + assert credentials.get_cred_info() == { + "credential_source": "/path/to/file", + "credential_type": "external account authorized user credentials", + } + + def test__make_copy_get_cred_info(self): + credentials = self.make_credentials() + credentials._cred_file_path = "/path/to/file" + cred_copy = credentials._make_copy() + assert cred_copy._cred_file_path == "/path/to/file" + + def test_default_state(self): + creds = self.make_credentials() + + assert not creds.expiry + assert not creds.expired + assert not creds.token + assert not creds.valid + assert not creds.requires_scopes + assert not creds.scopes + assert not creds.revoke_url + assert creds.token_info_url + assert creds.client_id + assert creds.client_secret + assert creds.is_user + assert creds.refresh_token == REFRESH_TOKEN + assert creds.audience == AUDIENCE + assert creds.token_url == TOKEN_URL + assert creds.universe_domain == DEFAULT_UNIVERSE_DOMAIN + + def test_basic_create(self): + creds = external_account_authorized_user.Credentials( + token=ACCESS_TOKEN, + expiry=datetime.datetime.max, + scopes=SCOPES, + revoke_url=REVOKE_URL, + universe_domain=FAKE_UNIVERSE_DOMAIN, + ) + + assert creds.expiry == datetime.datetime.max + assert not creds.expired + assert creds.token == ACCESS_TOKEN + assert creds.valid + assert not creds.requires_scopes + assert creds.scopes == SCOPES + assert creds.is_user + assert creds.revoke_url == REVOKE_URL + assert creds.universe_domain == FAKE_UNIVERSE_DOMAIN + + def test_stunted_create_no_refresh_token(self): + with pytest.raises(ValueError) as excinfo: + self.make_credentials(token=None, refresh_token=None) + + assert excinfo.match( + r"Token should be created with fields to make it valid \(`token` and " + r"`expiry`\), or fields to allow it to refresh \(`refresh_token`, " + r"`token_url`, `client_id`, `client_secret`\)\." + ) + + def test_stunted_create_no_token_url(self): + with pytest.raises(ValueError) as excinfo: + self.make_credentials(token=None, token_url=None) + + assert excinfo.match( + r"Token should be created with fields to make it valid \(`token` and " + r"`expiry`\), or fields to allow it to refresh \(`refresh_token`, " + r"`token_url`, `client_id`, `client_secret`\)\." + ) + + def test_stunted_create_no_client_id(self): + with pytest.raises(ValueError) as excinfo: + self.make_credentials(token=None, client_id=None) + + assert excinfo.match( + r"Token should be created with fields to make it valid \(`token` and " + r"`expiry`\), or fields to allow it to refresh \(`refresh_token`, " + r"`token_url`, `client_id`, `client_secret`\)\." + ) + + def test_stunted_create_no_client_secret(self): + with pytest.raises(ValueError) as excinfo: + self.make_credentials(token=None, client_secret=None) + + assert excinfo.match( + r"Token should be created with fields to make it valid \(`token` and " + r"`expiry`\), or fields to allow it to refresh \(`refresh_token`, " + r"`token_url`, `client_id`, `client_secret`\)\." + ) + + @mock.patch("google.auth._helpers.utcnow", return_value=NOW) + def test_refresh_auth_success(self, utcnow): + request = self.make_mock_request( + status=http_client.OK, + data={"access_token": ACCESS_TOKEN, "expires_in": 3600}, + ) + creds = self.make_credentials() + + creds.refresh(request) + + assert creds.expiry == utcnow() + datetime.timedelta(seconds=3600) + assert not creds.expired + assert creds.token == ACCESS_TOKEN + assert creds.valid + assert not creds.requires_scopes + assert creds.is_user + assert creds._refresh_token_val == REFRESH_TOKEN + + request.assert_called_once_with( + url=TOKEN_URL, + method="POST", + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Basic " + BASIC_AUTH_ENCODING, + }, + body=("grant_type=refresh_token&refresh_token=" + REFRESH_TOKEN).encode( + "UTF-8" + ), + ) + + @mock.patch("google.auth._helpers.utcnow", return_value=NOW) + def test_refresh_auth_success_new_refresh_token(self, utcnow): + request = self.make_mock_request( + status=http_client.OK, + data={ + "access_token": ACCESS_TOKEN, + "expires_in": 3600, + "refresh_token": NEW_REFRESH_TOKEN, + }, + ) + creds = self.make_credentials() + + creds.refresh(request) + + assert creds.expiry == utcnow() + datetime.timedelta(seconds=3600) + assert not creds.expired + assert creds.token == ACCESS_TOKEN + assert creds.valid + assert not creds.requires_scopes + assert creds.is_user + assert creds._refresh_token_val == NEW_REFRESH_TOKEN + + request.assert_called_once_with( + url=TOKEN_URL, + method="POST", + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Basic " + BASIC_AUTH_ENCODING, + }, + body=("grant_type=refresh_token&refresh_token=" + REFRESH_TOKEN).encode( + "UTF-8" + ), + ) + + def test_refresh_auth_failure(self): + request = self.make_mock_request( + status=http_client.BAD_REQUEST, + data={ + "error": "invalid_request", + "error_description": "Invalid subject token", + "error_uri": "https://tools.ietf.org/html/rfc6749", + }, + ) + creds = self.make_credentials() + + with pytest.raises(exceptions.OAuthError) as excinfo: + creds.refresh(request) + + assert excinfo.match( + r"Error code invalid_request: Invalid subject token - https://tools.ietf.org/html/rfc6749" + ) + + assert not creds.expiry + assert not creds.expired + assert not creds.token + assert not creds.valid + assert not creds.requires_scopes + assert creds.is_user + + request.assert_called_once_with( + url=TOKEN_URL, + method="POST", + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Basic " + BASIC_AUTH_ENCODING, + }, + body=("grant_type=refresh_token&refresh_token=" + REFRESH_TOKEN).encode( + "UTF-8" + ), + ) + + def test_refresh_without_refresh_token(self): + request = self.make_mock_request() + creds = self.make_credentials(refresh_token=None, token=ACCESS_TOKEN) + + with pytest.raises(exceptions.RefreshError) as excinfo: + creds.refresh(request) + + assert excinfo.match( + r"The credentials do not contain the necessary fields need to refresh the access token. You must specify refresh_token, token_url, client_id, and client_secret." + ) + + assert not creds.expiry + assert not creds.expired + assert not creds.requires_scopes + assert creds.is_user + + request.assert_not_called() + + def test_refresh_without_token_url(self): + request = self.make_mock_request() + creds = self.make_credentials(token_url=None, token=ACCESS_TOKEN) + + with pytest.raises(exceptions.RefreshError) as excinfo: + creds.refresh(request) + + assert excinfo.match( + r"The credentials do not contain the necessary fields need to refresh the access token. You must specify refresh_token, token_url, client_id, and client_secret." + ) + + assert not creds.expiry + assert not creds.expired + assert not creds.requires_scopes + assert creds.is_user + + request.assert_not_called() + + def test_refresh_without_client_id(self): + request = self.make_mock_request() + creds = self.make_credentials(client_id=None, token=ACCESS_TOKEN) + + with pytest.raises(exceptions.RefreshError) as excinfo: + creds.refresh(request) + + assert excinfo.match( + r"The credentials do not contain the necessary fields need to refresh the access token. You must specify refresh_token, token_url, client_id, and client_secret." + ) + + assert not creds.expiry + assert not creds.expired + assert not creds.requires_scopes + assert creds.is_user + + request.assert_not_called() + + def test_refresh_without_client_secret(self): + request = self.make_mock_request() + creds = self.make_credentials(client_secret=None, token=ACCESS_TOKEN) + + with pytest.raises(exceptions.RefreshError) as excinfo: + creds.refresh(request) + + assert excinfo.match( + r"The credentials do not contain the necessary fields need to refresh the access token. You must specify refresh_token, token_url, client_id, and client_secret." + ) + + assert not creds.expiry + assert not creds.expired + assert not creds.requires_scopes + assert creds.is_user + + request.assert_not_called() + + def test_revoke_auth_success(self): + request = self.make_mock_request(status=http_client.OK, data={}) + creds = self.make_credentials(revoke_url=REVOKE_URL) + + creds.revoke(request) + + request.assert_called_once_with( + url=REVOKE_URL, + method="POST", + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Basic " + BASIC_AUTH_ENCODING, + }, + body=("token=" + REFRESH_TOKEN + "&token_type_hint=refresh_token").encode( + "utf-8" + ), + ) + assert creds.token is None + assert creds._refresh_token is None + + def test_revoke_without_revoke_url(self): + request = self.make_mock_request() + creds = self.make_credentials(token=ACCESS_TOKEN) + + with pytest.raises(exceptions.OAuthError) as excinfo: + creds.revoke(request) + + assert excinfo.match( + r"The credentials do not contain the necessary fields to revoke the refresh token. You must specify revoke_url and refresh_token." + ) + + def test_revoke_without_refresh_token(self): + request = self.make_mock_request() + creds = self.make_credentials( + refresh_token=None, token=ACCESS_TOKEN, revoke_url=REVOKE_URL + ) + + with pytest.raises(exceptions.OAuthError) as excinfo: + creds.revoke(request) + + assert excinfo.match( + r"The credentials do not contain the necessary fields to revoke the refresh token. You must specify revoke_url and refresh_token." + ) + + def test_info(self): + creds = self.make_credentials() + info = creds.info + + assert info["audience"] == AUDIENCE + assert info["refresh_token"] == REFRESH_TOKEN + assert info["token_url"] == TOKEN_URL + assert info["token_info_url"] == TOKEN_INFO_URL + assert info["client_id"] == CLIENT_ID + assert info["client_secret"] == CLIENT_SECRET + assert info["universe_domain"] == DEFAULT_UNIVERSE_DOMAIN + assert "token" not in info + assert "expiry" not in info + assert "revoke_url" not in info + assert "quota_project_id" not in info + + def test_info_full(self): + creds = self.make_credentials( + token=ACCESS_TOKEN, + expiry=NOW, + revoke_url=REVOKE_URL, + quota_project_id=QUOTA_PROJECT_ID, + universe_domain=FAKE_UNIVERSE_DOMAIN, + ) + info = creds.info + + assert info["audience"] == AUDIENCE + assert info["refresh_token"] == REFRESH_TOKEN + assert info["token_url"] == TOKEN_URL + assert info["token_info_url"] == TOKEN_INFO_URL + assert info["client_id"] == CLIENT_ID + assert info["client_secret"] == CLIENT_SECRET + assert info["token"] == ACCESS_TOKEN + assert info["expiry"] == NOW.isoformat() + "Z" + assert info["revoke_url"] == REVOKE_URL + assert info["quota_project_id"] == QUOTA_PROJECT_ID + assert info["universe_domain"] == FAKE_UNIVERSE_DOMAIN + + def test_to_json(self): + creds = self.make_credentials() + json_info = creds.to_json() + info = json.loads(json_info) + + assert info["audience"] == AUDIENCE + assert info["refresh_token"] == REFRESH_TOKEN + assert info["token_url"] == TOKEN_URL + assert info["token_info_url"] == TOKEN_INFO_URL + assert info["client_id"] == CLIENT_ID + assert info["client_secret"] == CLIENT_SECRET + assert info["universe_domain"] == DEFAULT_UNIVERSE_DOMAIN + assert "token" not in info + assert "expiry" not in info + assert "revoke_url" not in info + assert "quota_project_id" not in info + + def test_to_json_full(self): + creds = self.make_credentials( + token=ACCESS_TOKEN, + expiry=NOW, + revoke_url=REVOKE_URL, + quota_project_id=QUOTA_PROJECT_ID, + universe_domain=FAKE_UNIVERSE_DOMAIN, + ) + json_info = creds.to_json() + info = json.loads(json_info) + + assert info["audience"] == AUDIENCE + assert info["refresh_token"] == REFRESH_TOKEN + assert info["token_url"] == TOKEN_URL + assert info["token_info_url"] == TOKEN_INFO_URL + assert info["client_id"] == CLIENT_ID + assert info["client_secret"] == CLIENT_SECRET + assert info["token"] == ACCESS_TOKEN + assert info["expiry"] == NOW.isoformat() + "Z" + assert info["revoke_url"] == REVOKE_URL + assert info["quota_project_id"] == QUOTA_PROJECT_ID + assert info["universe_domain"] == FAKE_UNIVERSE_DOMAIN + + def test_to_json_full_with_strip(self): + creds = self.make_credentials( + token=ACCESS_TOKEN, + expiry=NOW, + revoke_url=REVOKE_URL, + quota_project_id=QUOTA_PROJECT_ID, + ) + json_info = creds.to_json(strip=["token", "expiry"]) + info = json.loads(json_info) + + assert info["audience"] == AUDIENCE + assert info["refresh_token"] == REFRESH_TOKEN + assert info["token_url"] == TOKEN_URL + assert info["token_info_url"] == TOKEN_INFO_URL + assert info["client_id"] == CLIENT_ID + assert info["client_secret"] == CLIENT_SECRET + assert "token" not in info + assert "expiry" not in info + assert info["revoke_url"] == REVOKE_URL + assert info["quota_project_id"] == QUOTA_PROJECT_ID + + def test_get_project_id(self): + creds = self.make_credentials() + request = mock.create_autospec(transport.Request) + + assert creds.get_project_id(request) is None + request.assert_not_called() + + def test_with_quota_project(self): + creds = self.make_credentials( + token=ACCESS_TOKEN, + expiry=NOW, + revoke_url=REVOKE_URL, + quota_project_id=QUOTA_PROJECT_ID, + ) + new_creds = creds.with_quota_project(QUOTA_PROJECT_ID) + assert new_creds._audience == creds._audience + assert new_creds._refresh_token_val == creds.refresh_token + assert new_creds._token_url == creds._token_url + assert new_creds._token_info_url == creds._token_info_url + assert new_creds._client_id == creds._client_id + assert new_creds._client_secret == creds._client_secret + assert new_creds.token == creds.token + assert new_creds.expiry == creds.expiry + assert new_creds._revoke_url == creds._revoke_url + assert new_creds._quota_project_id == QUOTA_PROJECT_ID + + def test_with_token_uri(self): + creds = self.make_credentials( + token=ACCESS_TOKEN, + expiry=NOW, + revoke_url=REVOKE_URL, + quota_project_id=QUOTA_PROJECT_ID, + ) + new_creds = creds.with_token_uri("https://google.com") + assert new_creds._audience == creds._audience + assert new_creds._refresh_token_val == creds.refresh_token + assert new_creds._token_url == "https://google.com" + assert new_creds._token_info_url == creds._token_info_url + assert new_creds._client_id == creds._client_id + assert new_creds._client_secret == creds._client_secret + assert new_creds.token == creds.token + assert new_creds.expiry == creds.expiry + assert new_creds._revoke_url == creds._revoke_url + assert new_creds._quota_project_id == creds._quota_project_id + + def test_with_universe_domain(self): + creds = self.make_credentials( + token=ACCESS_TOKEN, + expiry=NOW, + revoke_url=REVOKE_URL, + quota_project_id=QUOTA_PROJECT_ID, + ) + new_creds = creds.with_universe_domain(FAKE_UNIVERSE_DOMAIN) + assert new_creds._audience == creds._audience + assert new_creds._refresh_token_val == creds.refresh_token + assert new_creds._token_url == creds._token_url + assert new_creds._token_info_url == creds._token_info_url + assert new_creds._client_id == creds._client_id + assert new_creds._client_secret == creds._client_secret + assert new_creds.token == creds.token + assert new_creds.expiry == creds.expiry + assert new_creds._revoke_url == creds._revoke_url + assert new_creds._quota_project_id == QUOTA_PROJECT_ID + assert new_creds.universe_domain == FAKE_UNIVERSE_DOMAIN + + def test_with_trust_boundary(self): + creds = self.make_credentials( + token=ACCESS_TOKEN, + expiry=NOW, + revoke_url=REVOKE_URL, + quota_project_id=QUOTA_PROJECT_ID, + ) + new_creds = creds.with_trust_boundary({"encodedLocations": "new_boundary"}) + assert new_creds._audience == creds._audience + assert new_creds._refresh_token_val == creds.refresh_token + assert new_creds._token_url == creds._token_url + assert new_creds._token_info_url == creds._token_info_url + assert new_creds._client_id == creds._client_id + assert new_creds._client_secret == creds._client_secret + assert new_creds.token == creds.token + assert new_creds.expiry == creds.expiry + assert new_creds._revoke_url == creds._revoke_url + assert new_creds._quota_project_id == QUOTA_PROJECT_ID + assert new_creds._trust_boundary == {"encodedLocations": "new_boundary"} + + def test_from_file_required_options_only(self, tmpdir): + from_creds = self.make_credentials() + config_file = tmpdir.join("config.json") + config_file.write(from_creds.to_json()) + creds = external_account_authorized_user.Credentials.from_file(str(config_file)) + + assert isinstance(creds, external_account_authorized_user.Credentials) + assert creds.audience == AUDIENCE + assert creds.refresh_token == REFRESH_TOKEN + assert creds.token_url == TOKEN_URL + assert creds.token_info_url == TOKEN_INFO_URL + assert creds.client_id == CLIENT_ID + assert creds.client_secret == CLIENT_SECRET + assert creds.token is None + assert creds.expiry is None + assert creds.scopes is None + assert creds._revoke_url is None + assert creds._quota_project_id is None + + def test_from_file_full_options(self, tmpdir): + from_creds = self.make_credentials( + token=ACCESS_TOKEN, + expiry=NOW, + revoke_url=REVOKE_URL, + quota_project_id=QUOTA_PROJECT_ID, + scopes=SCOPES, + ) + config_file = tmpdir.join("config.json") + config_file.write(from_creds.to_json()) + creds = external_account_authorized_user.Credentials.from_file(str(config_file)) + + assert isinstance(creds, external_account_authorized_user.Credentials) + assert creds.audience == AUDIENCE + assert creds.refresh_token == REFRESH_TOKEN + assert creds.token_url == TOKEN_URL + assert creds.token_info_url == TOKEN_INFO_URL + assert creds.client_id == CLIENT_ID + assert creds.client_secret == CLIENT_SECRET + assert creds.token == ACCESS_TOKEN + assert creds.expiry == NOW + assert creds.scopes == SCOPES + assert creds._revoke_url == REVOKE_URL + assert creds._quota_project_id == QUOTA_PROJECT_ID + + def test_refresh_fetches_trust_boundary(self): + request = self.make_mock_request( + status=http_client.OK, + data={"access_token": ACCESS_TOKEN, "expires_in": 3600}, + ) + credentials = self.make_credentials() + + with mock.patch.object( + credentials, + "_lookup_trust_boundary", + return_value={"encodedLocations": "0x123"}, + ) as mock_lookup, mock.patch.dict( + os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + ): + credentials.refresh(request) + + mock_lookup.assert_called_once() + headers = {} + credentials.apply(headers) + assert headers["x-allowed-locations"] == "0x123" + + def test_refresh_skips_trust_boundary_lookup_when_disabled(self): + request = self.make_mock_request( + status=http_client.OK, + data={"access_token": ACCESS_TOKEN, "expires_in": 3600}, + ) + credentials = self.make_credentials() + + with mock.patch.object( + credentials, "_lookup_trust_boundary" + ) as mock_lookup, mock.patch.dict(os.environ, {}, clear=True): + credentials.refresh(request) + + mock_lookup.assert_not_called() + headers = {} + credentials.apply(headers) + assert "x-allowed-locations" not in headers + + def test_build_trust_boundary_lookup_url(self): + credentials = self.make_credentials() + expected_url = "https://iamcredentials.googleapis.com/v1/locations/global/workforcePools/POOL_ID/allowedLocations" + assert credentials._build_trust_boundary_lookup_url() == expected_url + + @pytest.mark.parametrize( + "audience", + [ + "invalid", + "//iam.googleapis.com/locations/global/workforcePools/", + "//iam.googleapis.com/locations/global/providers/", + "//iam.googleapis.com/workforcePools/POOL_ID/providers/PROVIDER_ID", + ], + ) + def test_build_trust_boundary_lookup_url_invalid_audience(self, audience): + credentials = self.make_credentials(audience=audience) + with pytest.raises(exceptions.InvalidValue): + credentials._build_trust_boundary_lookup_url() + + def test_build_trust_boundary_lookup_url_different_universe(self): + credentials = self.make_credentials(universe_domain=FAKE_UNIVERSE_DOMAIN) + expected_url = "https://iamcredentials.fake-universe-domain/v1/locations/global/workforcePools/POOL_ID/allowedLocations" + assert credentials._build_trust_boundary_lookup_url() == expected_url diff --git a/packages/google-auth/tests/test_iam.py b/packages/google-auth/tests/test_iam.py new file mode 100644 index 000000000000..01c2fa085aad --- /dev/null +++ b/packages/google-auth/tests/test_iam.py @@ -0,0 +1,115 @@ +# Copyright 2017 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 base64 +import datetime +import http.client as http_client +import json + +import mock +import pytest # type: ignore + +from google.auth import _helpers +from google.auth import exceptions +from google.auth import iam +from google.auth import transport +import google.auth.credentials + + +def make_request(status, data=None): + response = mock.create_autospec(transport.Response, instance=True) + response.status = status + + if data is not None: + response.data = json.dumps(data).encode("utf-8") + + request = mock.create_autospec(transport.Request) + request.return_value = response + return request + + +def make_credentials(): + class CredentialsImpl(google.auth.credentials.Credentials): + def __init__(self): + super(CredentialsImpl, self).__init__() + self.token = "token" + # Force refresh + self.expiry = datetime.datetime.min + _helpers.REFRESH_THRESHOLD + + def refresh(self, request): + pass + + def with_quota_project(self, quota_project_id): + raise NotImplementedError() + + return CredentialsImpl() + + +class TestSigner(object): + def test_constructor(self): + request = mock.sentinel.request + credentials = mock.create_autospec( + google.auth.credentials.Credentials, instance=True + ) + + signer = iam.Signer(request, credentials, mock.sentinel.service_account_email) + + assert signer._request == mock.sentinel.request + assert signer._credentials == credentials + assert signer._service_account_email == mock.sentinel.service_account_email + + def test_key_id(self): + signer = iam.Signer( + mock.sentinel.request, + mock.sentinel.credentials, + mock.sentinel.service_account_email, + ) + + assert signer.key_id is None + + def test_sign_bytes(self): + signature = b"DEADBEEF" + encoded_signature = base64.b64encode(signature).decode("utf-8") + request = make_request(http_client.OK, data={"signedBlob": encoded_signature}) + credentials = make_credentials() + + signer = iam.Signer(request, credentials, mock.sentinel.service_account_email) + + returned_signature = signer.sign("123") + + assert returned_signature == signature + kwargs = request.call_args[1] + assert kwargs["headers"]["Content-Type"] == "application/json" + request.call_count == 1 + + def test_sign_bytes_failure(self): + request = make_request(http_client.UNAUTHORIZED) + credentials = make_credentials() + + signer = iam.Signer(request, credentials, mock.sentinel.service_account_email) + + with pytest.raises(exceptions.TransportError): + signer.sign("123") + request.call_count == 1 + + @mock.patch("time.sleep", return_value=None) + def test_sign_bytes_retryable_failure(self, mock_time): + request = make_request(http_client.INTERNAL_SERVER_ERROR) + credentials = make_credentials() + + signer = iam.Signer(request, credentials, mock.sentinel.service_account_email) + + with pytest.raises(exceptions.TransportError): + signer.sign("123") + request.call_count == 3 diff --git a/packages/google-auth/tests/test_identity_pool.py b/packages/google-auth/tests/test_identity_pool.py new file mode 100644 index 000000000000..0a616cae90c5 --- /dev/null +++ b/packages/google-auth/tests/test_identity_pool.py @@ -0,0 +1,1773 @@ +# 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 base64 +import datetime +import http.client as http_client +import json +import os +import urllib + +import mock +from OpenSSL import crypto +import pytest # type: ignore + +from google.auth import _helpers, external_account +from google.auth import exceptions +from google.auth import identity_pool +from google.auth import metrics +from google.auth import transport +from google.auth.credentials import DEFAULT_UNIVERSE_DOMAIN + +CLIENT_ID = "username" +CLIENT_SECRET = "password" +# Base64 encoding of "username:password". +BASIC_AUTH_ENCODING = "dXNlcm5hbWU6cGFzc3dvcmQ=" +SERVICE_ACCOUNT_EMAIL = "service-1234@service-name.iam.gserviceaccount.com" +SERVICE_ACCOUNT_IMPERSONATION_URL_BASE = ( + "https://us-east1-iamcredentials.googleapis.com" +) +SERVICE_ACCOUNT_IMPERSONATION_URL_ROUTE = ( + "/v1/projects/-/serviceAccounts/{}:generateAccessToken".format( + SERVICE_ACCOUNT_EMAIL + ) +) +SERVICE_ACCOUNT_IMPERSONATION_URL = ( + SERVICE_ACCOUNT_IMPERSONATION_URL_BASE + SERVICE_ACCOUNT_IMPERSONATION_URL_ROUTE +) + +QUOTA_PROJECT_ID = "QUOTA_PROJECT_ID" +SCOPES = ["scope1", "scope2"] +DATA_DIR = os.path.join(os.path.dirname(__file__), "data") +SUBJECT_TOKEN_TEXT_FILE = os.path.join(DATA_DIR, "external_subject_token.txt") +SUBJECT_TOKEN_JSON_FILE = os.path.join(DATA_DIR, "external_subject_token.json") +TRUST_CHAIN_WITH_LEAF_FILE = os.path.join(DATA_DIR, "trust_chain_with_leaf.pem") +TRUST_CHAIN_WITHOUT_LEAF_FILE = os.path.join(DATA_DIR, "trust_chain_without_leaf.pem") +TRUST_CHAIN_WRONG_ORDER_FILE = os.path.join(DATA_DIR, "trust_chain_wrong_order.pem") +CERT_FILE = os.path.join(DATA_DIR, "public_cert.pem") +KEY_FILE = os.path.join(DATA_DIR, "privatekey.pem") +OTHER_CERT_FILE = os.path.join(DATA_DIR, "other_cert.pem") + +SUBJECT_TOKEN_FIELD_NAME = "access_token" + +with open(SUBJECT_TOKEN_TEXT_FILE) as fh: + TEXT_FILE_SUBJECT_TOKEN = fh.read() + +with open(SUBJECT_TOKEN_JSON_FILE) as fh: + JSON_FILE_CONTENT = json.load(fh) + JSON_FILE_SUBJECT_TOKEN = JSON_FILE_CONTENT.get(SUBJECT_TOKEN_FIELD_NAME) + +with open(CERT_FILE, "rb") as f: + CERT_FILE_CONTENT = base64.b64encode( + crypto.dump_certificate( + crypto.FILETYPE_ASN1, crypto.load_certificate(crypto.FILETYPE_PEM, f.read()) + ) + ).decode("utf-8") + +with open(OTHER_CERT_FILE, "rb") as f: + OTHER_CERT_FILE_CONTENT = base64.b64encode( + crypto.dump_certificate( + crypto.FILETYPE_ASN1, crypto.load_certificate(crypto.FILETYPE_PEM, f.read()) + ) + ).decode("utf-8") + +TOKEN_URL = "https://sts.googleapis.com/v1/token" +TOKEN_INFO_URL = "https://sts.googleapis.com/v1/introspect" +SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt" +AUDIENCE = "//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID" +WORKFORCE_AUDIENCE = ( + "//iam.googleapis.com/locations/global/workforcePools/POOL_ID/providers/PROVIDER_ID" +) +WORKFORCE_SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id_token" +WORKFORCE_POOL_USER_PROJECT = "WORKFORCE_POOL_USER_PROJECT_NUMBER" + +VALID_TOKEN_URLS = [ + "https://sts.googleapis.com", + "https://us-east-1.sts.googleapis.com", + "https://US-EAST-1.sts.googleapis.com", + "https://sts.us-east-1.googleapis.com", + "https://sts.US-WEST-1.googleapis.com", + "https://us-east-1-sts.googleapis.com", + "https://US-WEST-1-sts.googleapis.com", + "https://us-west-1-sts.googleapis.com/path?query", + "https://sts-us-east-1.p.googleapis.com", +] +INVALID_TOKEN_URLS = [ + "https://iamcredentials.googleapis.com", + "sts.googleapis.com", + "https://", + "http://sts.googleapis.com", + "https://st.s.googleapis.com", + "https://us-eas\t-1.sts.googleapis.com", + "https:/us-east-1.sts.googleapis.com", + "https://US-WE/ST-1-sts.googleapis.com", + "https://sts-us-east-1.googleapis.com", + "https://sts-US-WEST-1.googleapis.com", + "testhttps://us-east-1.sts.googleapis.com", + "https://us-east-1.sts.googleapis.comevil.com", + "https://us-east-1.us-east-1.sts.googleapis.com", + "https://us-ea.s.t.sts.googleapis.com", + "https://sts.googleapis.comevil.com", + "hhttps://us-east-1.sts.googleapis.com", + "https://us- -1.sts.googleapis.com", + "https://-sts.googleapis.com", + "https://us-east-1.sts.googleapis.com.evil.com", + "https://sts.pgoogleapis.com", + "https://p.googleapis.com", + "https://sts.p.com", + "http://sts.p.googleapis.com", + "https://xyz-sts.p.googleapis.com", + "https://sts-xyz.123.p.googleapis.com", + "https://sts-xyz.p1.googleapis.com", + "https://sts-xyz.p.foo.com", + "https://sts-xyz.p.foo.googleapis.com", +] +VALID_SERVICE_ACCOUNT_IMPERSONATION_URLS = [ + "https://iamcredentials.googleapis.com", + "https://us-east-1.iamcredentials.googleapis.com", + "https://US-EAST-1.iamcredentials.googleapis.com", + "https://iamcredentials.us-east-1.googleapis.com", + "https://iamcredentials.US-WEST-1.googleapis.com", + "https://us-east-1-iamcredentials.googleapis.com", + "https://US-WEST-1-iamcredentials.googleapis.com", + "https://us-west-1-iamcredentials.googleapis.com/path?query", + "https://iamcredentials-us-east-1.p.googleapis.com", +] +INVALID_SERVICE_ACCOUNT_IMPERSONATION_URLS = [ + "https://sts.googleapis.com", + "iamcredentials.googleapis.com", + "https://", + "http://iamcredentials.googleapis.com", + "https://iamcre.dentials.googleapis.com", + "https://us-eas\t-1.iamcredentials.googleapis.com", + "https:/us-east-1.iamcredentials.googleapis.com", + "https://US-WE/ST-1-iamcredentials.googleapis.com", + "https://iamcredentials-us-east-1.googleapis.com", + "https://iamcredentials-US-WEST-1.googleapis.com", + "testhttps://us-east-1.iamcredentials.googleapis.com", + "https://us-east-1.iamcredentials.googleapis.comevil.com", + "https://us-east-1.us-east-1.iamcredentials.googleapis.com", + "https://us-ea.s.t.iamcredentials.googleapis.com", + "https://iamcredentials.googleapis.comevil.com", + "hhttps://us-east-1.iamcredentials.googleapis.com", + "https://us- -1.iamcredentials.googleapis.com", + "https://-iamcredentials.googleapis.com", + "https://us-east-1.iamcredentials.googleapis.com.evil.com", + "https://iamcredentials.pgoogleapis.com", + "https://p.googleapis.com", + "https://iamcredentials.p.com", + "http://iamcredentials.p.googleapis.com", + "https://xyz-iamcredentials.p.googleapis.com", + "https://iamcredentials-xyz.123.p.googleapis.com", + "https://iamcredentials-xyz.p1.googleapis.com", + "https://iamcredentials-xyz.p.foo.com", + "https://iamcredentials-xyz.p.foo.googleapis.com", +] + + +class TestSubjectTokenSupplier(identity_pool.SubjectTokenSupplier): + def __init__( + self, subject_token=None, subject_token_exception=None, expected_context=None + ): + self._subject_token = subject_token + self._subject_token_exception = subject_token_exception + self._expected_context = expected_context + + def get_subject_token(self, context, request): + if self._expected_context is not None: + assert self._expected_context == context + if self._subject_token_exception is not None: + raise self._subject_token_exception + return self._subject_token + + +class TestCredentials(object): + CREDENTIAL_SOURCE_TEXT = {"file": SUBJECT_TOKEN_TEXT_FILE} + CREDENTIAL_SOURCE_JSON = { + "file": SUBJECT_TOKEN_JSON_FILE, + "format": {"type": "json", "subject_token_field_name": "access_token"}, + } + CREDENTIAL_URL = "http://fakeurl.com" + CREDENTIAL_SOURCE_TEXT_URL = {"url": CREDENTIAL_URL} + CREDENTIAL_SOURCE_JSON_URL = { + "url": CREDENTIAL_URL, + "format": {"type": "json", "subject_token_field_name": "access_token"}, + } + CREDENTIAL_SOURCE_CERTIFICATE = { + "certificate": {"use_default_certificate_config": "true"} + } + CREDENTIAL_SOURCE_CERTIFICATE_NOT_DEFAULT = { + "certificate": {"certificate_config_location": "path/to/config"} + } + CREDENTIAL_SOURCE_CERTIFICATE_TRUST_CHAIN_WITH_LEAF = { + "certificate": { + "use_default_certificate_config": "true", + "trust_chain_path": TRUST_CHAIN_WITH_LEAF_FILE, + } + } + CREDENTIAL_SOURCE_CERTIFICATE_TRUST_CHAIN_WITHOUT_LEAF = { + "certificate": { + "use_default_certificate_config": "true", + "trust_chain_path": TRUST_CHAIN_WITHOUT_LEAF_FILE, + } + } + CREDENTIAL_SOURCE_CERTIFICATE_TRUST_CHAIN_WRONG_ORDER = { + "certificate": { + "use_default_certificate_config": "true", + "trust_chain_path": TRUST_CHAIN_WRONG_ORDER_FILE, + } + } + SUCCESS_RESPONSE = { + "access_token": "ACCESS_TOKEN", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "Bearer", + "expires_in": 3600, + "scope": " ".join(SCOPES), + } + + @classmethod + def make_mock_response(cls, status, data): + response = mock.create_autospec(transport.Response, instance=True) + response.status = status + if isinstance(data, dict): + response.data = json.dumps(data).encode("utf-8") + else: + response.data = data + return response + + @classmethod + def make_mock_request( + cls, token_status=http_client.OK, token_data=None, *extra_requests + ): + responses = [] + responses.append(cls.make_mock_response(token_status, token_data)) + + while len(extra_requests) > 0: + # If service account impersonation is requested, mock the expected response. + status, data, extra_requests = ( + extra_requests[0], + extra_requests[1], + extra_requests[2:], + ) + responses.append(cls.make_mock_response(status, data)) + + request = mock.create_autospec(transport.Request) + request.side_effect = responses + + return request + + @classmethod + def assert_credential_request_kwargs( + cls, request_kwargs, headers, url=CREDENTIAL_URL + ): + assert request_kwargs["url"] == url + assert request_kwargs["method"] == "GET" + assert request_kwargs["headers"] == headers + assert request_kwargs.get("body", None) is None + + @classmethod + def assert_token_request_kwargs( + cls, request_kwargs, headers, request_data, token_url=TOKEN_URL + ): + assert request_kwargs["url"] == token_url + assert request_kwargs["method"] == "POST" + assert request_kwargs["headers"] == headers + assert request_kwargs["body"] is not None + body_tuples = urllib.parse.parse_qsl(request_kwargs["body"]) + assert len(body_tuples) == len(request_data.keys()) + for k, v in body_tuples: + assert v.decode("utf-8") == request_data[k.decode("utf-8")] + + @classmethod + def assert_impersonation_request_kwargs( + cls, + request_kwargs, + headers, + request_data, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + ): + assert request_kwargs["url"] == service_account_impersonation_url + assert request_kwargs["method"] == "POST" + assert request_kwargs["headers"] == headers + assert request_kwargs["body"] is not None + body_json = json.loads(request_kwargs["body"].decode("utf-8")) + assert body_json == request_data + + @classmethod + def assert_underlying_credentials_refresh( + cls, + credentials, + audience, + subject_token, + subject_token_type, + token_url, + service_account_impersonation_url=None, + basic_auth_encoding=None, + quota_project_id=None, + used_scopes=None, + credential_data=None, + scopes=None, + default_scopes=None, + workforce_pool_user_project=None, + ): + """Utility to assert that a credentials are initialized with the expected + attributes by calling refresh functionality and confirming response matches + expected one and that the underlying requests were populated with the + expected parameters. + """ + # STS token exchange request/response. + token_response = cls.SUCCESS_RESPONSE.copy() + token_headers = {"Content-Type": "application/x-www-form-urlencoded"} + if basic_auth_encoding: + token_headers["Authorization"] = "Basic " + basic_auth_encoding + + metrics_options = {} + if credentials._service_account_impersonation_url: + metrics_options["sa-impersonation"] = "true" + else: + metrics_options["sa-impersonation"] = "false" + metrics_options["config-lifetime"] = "false" + if credentials._credential_source: + if credentials._credential_source_file: + metrics_options["source"] = "file" + else: + metrics_options["source"] = "url" + else: + metrics_options["source"] = "programmatic" + + token_headers["x-goog-api-client"] = metrics.byoid_metrics_header( + metrics_options + ) + + if service_account_impersonation_url: + token_scopes = "https://www.googleapis.com/auth/iam" + else: + token_scopes = " ".join(used_scopes or []) + + token_request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": audience, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "scope": token_scopes, + "subject_token": subject_token, + "subject_token_type": subject_token_type, + } + if workforce_pool_user_project: + token_request_data["options"] = urllib.parse.quote( + json.dumps({"userProject": workforce_pool_user_project}) + ) + + metrics_header_value = ( + "gl-python/3.7 auth/1.1 auth-request-type/at cred-type/imp" + ) + if service_account_impersonation_url: + # Service account impersonation request/response. + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + + datetime.timedelta(seconds=3600) + ).isoformat("T") + "Z" + impersonation_response = { + "accessToken": "SA_ACCESS_TOKEN", + "expireTime": expire_time, + } + impersonation_headers = { + "Content-Type": "application/json", + "authorization": "Bearer {}".format(token_response["access_token"]), + "x-goog-api-client": metrics_header_value, + # TODO(negarb): Uncomment and update when trust boundary is supported + # for external account credentials. + # "x-allowed-locations": "0x0", + } + impersonation_request_data = { + "delegates": None, + "scope": used_scopes, + "lifetime": "3600s", + } + + # Initialize mock request to handle token retrieval, token exchange and + # service account impersonation request. + requests = [] + if credential_data: + requests.append((http_client.OK, credential_data)) + + token_request_index = len(requests) + requests.append((http_client.OK, token_response)) + + if service_account_impersonation_url: + impersonation_request_index = len(requests) + requests.append((http_client.OK, impersonation_response)) + + request = cls.make_mock_request(*[el for req in requests for el in req]) + + with mock.patch( + "google.auth.metrics.token_request_access_token_impersonate", + return_value=metrics_header_value, + ): + credentials.refresh(request) + + assert len(request.call_args_list) == len(requests) + if credential_data: + cls.assert_credential_request_kwargs(request.call_args_list[0][1], None) + # Verify token exchange request parameters. + cls.assert_token_request_kwargs( + request.call_args_list[token_request_index][1], + token_headers, + token_request_data, + token_url, + ) + # Verify service account impersonation request parameters if the request + # is processed. + if service_account_impersonation_url: + cls.assert_impersonation_request_kwargs( + request.call_args_list[impersonation_request_index][1], + impersonation_headers, + impersonation_request_data, + service_account_impersonation_url, + ) + assert credentials.token == impersonation_response["accessToken"] + else: + assert credentials.token == token_response["access_token"] + assert credentials.quota_project_id == quota_project_id + assert credentials.scopes == scopes + assert credentials.default_scopes == default_scopes + + @classmethod + def make_credentials( + cls, + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + token_info_url=TOKEN_INFO_URL, + client_id=None, + client_secret=None, + quota_project_id=None, + scopes=None, + default_scopes=None, + service_account_impersonation_url=None, + credential_source=None, + subject_token_supplier=None, + workforce_pool_user_project=None, + ): + return identity_pool.Credentials( + audience=audience, + subject_token_type=subject_token_type, + token_url=token_url, + token_info_url=token_info_url, + service_account_impersonation_url=service_account_impersonation_url, + credential_source=credential_source, + subject_token_supplier=subject_token_supplier, + client_id=client_id, + client_secret=client_secret, + quota_project_id=quota_project_id, + scopes=scopes, + default_scopes=default_scopes, + workforce_pool_user_project=workforce_pool_user_project, + ) + + @mock.patch.object(identity_pool.Credentials, "__init__", return_value=None) + def test_from_info_full_options(self, mock_init): + credentials = identity_pool.Credentials.from_info( + { + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "token_info_url": TOKEN_INFO_URL, + "service_account_impersonation_url": SERVICE_ACCOUNT_IMPERSONATION_URL, + "service_account_impersonation": {"token_lifetime_seconds": 2800}, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "quota_project_id": QUOTA_PROJECT_ID, + "credential_source": self.CREDENTIAL_SOURCE_TEXT, + } + ) + + # Confirm identity_pool.Credentials instantiated with expected attributes. + assert isinstance(credentials, identity_pool.Credentials) + mock_init.assert_called_once_with( + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + token_info_url=TOKEN_INFO_URL, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + service_account_impersonation_options={"token_lifetime_seconds": 2800}, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + credential_source=self.CREDENTIAL_SOURCE_TEXT, + subject_token_supplier=None, + quota_project_id=QUOTA_PROJECT_ID, + workforce_pool_user_project=None, + universe_domain=DEFAULT_UNIVERSE_DOMAIN, + trust_boundary=None, + ) + + @mock.patch.object(identity_pool.Credentials, "__init__", return_value=None) + def test_from_info_required_options_only(self, mock_init): + credentials = identity_pool.Credentials.from_info( + { + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "credential_source": self.CREDENTIAL_SOURCE_TEXT, + } + ) + + # Confirm identity_pool.Credentials instantiated with expected attributes. + assert isinstance(credentials, identity_pool.Credentials) + mock_init.assert_called_once_with( + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + token_info_url=None, + service_account_impersonation_url=None, + service_account_impersonation_options={}, + client_id=None, + client_secret=None, + credential_source=self.CREDENTIAL_SOURCE_TEXT, + subject_token_supplier=None, + quota_project_id=None, + workforce_pool_user_project=None, + universe_domain=DEFAULT_UNIVERSE_DOMAIN, + trust_boundary=None, + ) + + @mock.patch.object(identity_pool.Credentials, "__init__", return_value=None) + def test_from_info_supplier(self, mock_init): + supplier = TestSubjectTokenSupplier() + + credentials = identity_pool.Credentials.from_info( + { + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "subject_token_supplier": supplier, + } + ) + + # Confirm identity_pool.Credentials instantiated with expected attributes. + assert isinstance(credentials, identity_pool.Credentials) + mock_init.assert_called_once_with( + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + token_info_url=None, + service_account_impersonation_url=None, + service_account_impersonation_options={}, + client_id=None, + client_secret=None, + credential_source=None, + subject_token_supplier=supplier, + quota_project_id=None, + workforce_pool_user_project=None, + universe_domain=DEFAULT_UNIVERSE_DOMAIN, + trust_boundary=None, + ) + + @mock.patch.object(identity_pool.Credentials, "__init__", return_value=None) + def test_from_info_workforce_pool(self, mock_init): + credentials = identity_pool.Credentials.from_info( + { + "audience": WORKFORCE_AUDIENCE, + "subject_token_type": WORKFORCE_SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "credential_source": self.CREDENTIAL_SOURCE_TEXT, + "workforce_pool_user_project": WORKFORCE_POOL_USER_PROJECT, + } + ) + + # Confirm identity_pool.Credentials instantiated with expected attributes. + assert isinstance(credentials, identity_pool.Credentials) + mock_init.assert_called_once_with( + audience=WORKFORCE_AUDIENCE, + subject_token_type=WORKFORCE_SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + token_info_url=None, + service_account_impersonation_url=None, + service_account_impersonation_options={}, + client_id=None, + client_secret=None, + credential_source=self.CREDENTIAL_SOURCE_TEXT, + subject_token_supplier=None, + quota_project_id=None, + workforce_pool_user_project=WORKFORCE_POOL_USER_PROJECT, + universe_domain=DEFAULT_UNIVERSE_DOMAIN, + trust_boundary=None, + ) + + @mock.patch.object(identity_pool.Credentials, "__init__", return_value=None) + def test_from_file_full_options(self, mock_init, tmpdir): + info = { + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "token_info_url": TOKEN_INFO_URL, + "service_account_impersonation_url": SERVICE_ACCOUNT_IMPERSONATION_URL, + "service_account_impersonation": {"token_lifetime_seconds": 2800}, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "quota_project_id": QUOTA_PROJECT_ID, + "credential_source": self.CREDENTIAL_SOURCE_TEXT, + } + config_file = tmpdir.join("config.json") + config_file.write(json.dumps(info)) + credentials = identity_pool.Credentials.from_file(str(config_file)) + + # Confirm identity_pool.Credentials instantiated with expected attributes. + assert isinstance(credentials, identity_pool.Credentials) + mock_init.assert_called_once_with( + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + token_info_url=TOKEN_INFO_URL, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + service_account_impersonation_options={"token_lifetime_seconds": 2800}, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + credential_source=self.CREDENTIAL_SOURCE_TEXT, + subject_token_supplier=None, + quota_project_id=QUOTA_PROJECT_ID, + workforce_pool_user_project=None, + universe_domain=DEFAULT_UNIVERSE_DOMAIN, + trust_boundary=None, + ) + + @mock.patch.object(identity_pool.Credentials, "__init__", return_value=None) + def test_from_file_required_options_only(self, mock_init, tmpdir): + info = { + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "credential_source": self.CREDENTIAL_SOURCE_TEXT, + } + config_file = tmpdir.join("config.json") + config_file.write(json.dumps(info)) + credentials = identity_pool.Credentials.from_file(str(config_file)) + + # Confirm identity_pool.Credentials instantiated with expected attributes. + assert isinstance(credentials, identity_pool.Credentials) + mock_init.assert_called_once_with( + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + token_info_url=None, + service_account_impersonation_url=None, + service_account_impersonation_options={}, + client_id=None, + client_secret=None, + credential_source=self.CREDENTIAL_SOURCE_TEXT, + subject_token_supplier=None, + quota_project_id=None, + workforce_pool_user_project=None, + universe_domain=DEFAULT_UNIVERSE_DOMAIN, + trust_boundary=None, + ) + + @mock.patch.object(identity_pool.Credentials, "__init__", return_value=None) + def test_from_file_workforce_pool(self, mock_init, tmpdir): + info = { + "audience": WORKFORCE_AUDIENCE, + "subject_token_type": WORKFORCE_SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "credential_source": self.CREDENTIAL_SOURCE_TEXT, + "workforce_pool_user_project": WORKFORCE_POOL_USER_PROJECT, + } + config_file = tmpdir.join("config.json") + config_file.write(json.dumps(info)) + credentials = identity_pool.Credentials.from_file(str(config_file)) + + # Confirm identity_pool.Credentials instantiated with expected attributes. + assert isinstance(credentials, identity_pool.Credentials) + mock_init.assert_called_once_with( + audience=WORKFORCE_AUDIENCE, + subject_token_type=WORKFORCE_SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + token_info_url=None, + service_account_impersonation_url=None, + service_account_impersonation_options={}, + client_id=None, + client_secret=None, + credential_source=self.CREDENTIAL_SOURCE_TEXT, + subject_token_supplier=None, + quota_project_id=None, + workforce_pool_user_project=WORKFORCE_POOL_USER_PROJECT, + universe_domain=DEFAULT_UNIVERSE_DOMAIN, + trust_boundary=None, + ) + + def test_constructor_nonworkforce_with_workforce_pool_user_project(self): + with pytest.raises(ValueError) as excinfo: + self.make_credentials( + audience=AUDIENCE, + workforce_pool_user_project=WORKFORCE_POOL_USER_PROJECT, + ) + + assert excinfo.match( + "workforce_pool_user_project should not be set for non-workforce " + "pool credentials" + ) + + def test_constructor_invalid_options(self): + credential_source = {"unsupported": "value"} + + with pytest.raises(ValueError) as excinfo: + self.make_credentials(credential_source=credential_source) + + assert excinfo.match(r"Missing credential_source") + + def test_constructor_invalid_options_url_and_file(self): + credential_source = { + "url": self.CREDENTIAL_URL, + "file": SUBJECT_TOKEN_TEXT_FILE, + } + + with pytest.raises(ValueError) as excinfo: + self.make_credentials(credential_source=credential_source) + + assert excinfo.match(r"Ambiguous credential_source") + + def test_constructor_invalid_options_url_and_certificate(self): + credential_source = { + "url": self.CREDENTIAL_URL, + "certificate": {"certificate": {"use_default_certificate_config": True}}, + } + + with pytest.raises(ValueError) as excinfo: + self.make_credentials(credential_source=credential_source) + + assert excinfo.match(r"Ambiguous credential_source") + + def test_constructor_invalid_options_file_and_certificate(self): + credential_source = { + "file": SUBJECT_TOKEN_TEXT_FILE, + "certificate": {"certificate": {"use_default_certificate": True}}, + } + + with pytest.raises(ValueError) as excinfo: + self.make_credentials(credential_source=credential_source) + + assert excinfo.match(r"Ambiguous credential_source") + + def test_constructor_invalid_options_url_file_and_certificate(self): + credential_source = { + "file": SUBJECT_TOKEN_TEXT_FILE, + "url": self.CREDENTIAL_URL, + "certificate": {"certificate": {"use_default_certificate": True}}, + } + + with pytest.raises(ValueError) as excinfo: + self.make_credentials(credential_source=credential_source) + + assert excinfo.match(r"Ambiguous credential_source") + + def test_constructor_invalid_options_environment_id(self): + credential_source = {"url": self.CREDENTIAL_URL, "environment_id": "aws1"} + + with pytest.raises(ValueError) as excinfo: + self.make_credentials(credential_source=credential_source) + + assert excinfo.match( + r"Invalid Identity Pool credential_source field 'environment_id'" + ) + + def test_constructor_invalid_credential_source(self): + with pytest.raises(ValueError) as excinfo: + self.make_credentials(credential_source="non-dict") + + assert excinfo.match( + r"Invalid credential_source. The credential_source is not a dict." + ) + + def test_constructor_invalid_no_credential_source_or_supplier(self): + with pytest.raises(ValueError) as excinfo: + self.make_credentials() + + assert excinfo.match( + r"A valid credential source or a subject token supplier must be provided." + ) + + def test_constructor_invalid_both_credential_source_and_supplier(self): + supplier = TestSubjectTokenSupplier() + with pytest.raises(ValueError) as excinfo: + self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE_TEXT, + subject_token_supplier=supplier, + ) + + assert excinfo.match( + r"Identity pool credential cannot have both a credential source and a subject token supplier." + ) + + def test_constructor_invalid_credential_source_format_type(self): + credential_source = {"file": "test.txt", "format": {"type": "xml"}} + + with pytest.raises(ValueError) as excinfo: + self.make_credentials(credential_source=credential_source) + + assert excinfo.match(r"Invalid credential_source format 'xml'") + + def test_constructor_missing_subject_token_field_name(self): + credential_source = {"file": "test.txt", "format": {"type": "json"}} + + with pytest.raises(ValueError) as excinfo: + self.make_credentials(credential_source=credential_source) + + assert excinfo.match( + r"Missing subject_token_field_name for JSON credential_source format" + ) + + def test_constructor_default_and_file_location_certificate(self): + credential_source = { + "certificate": { + "use_default_certificate_config": True, + "certificate_config_location": "test", + } + } + + with pytest.raises(ValueError) as excinfo: + self.make_credentials(credential_source=credential_source) + + assert excinfo.match(r"Invalid certificate configuration") + + def test_constructor_no_default_or_file_location_certificate(self): + credential_source = {"certificate": {"use_default_certificate_config": False}} + + with pytest.raises(ValueError) as excinfo: + self.make_credentials(credential_source=credential_source) + + assert excinfo.match(r"Invalid certificate configuration") + + def test_info_with_workforce_pool_user_project(self): + credentials = self.make_credentials( + audience=WORKFORCE_AUDIENCE, + subject_token_type=WORKFORCE_SUBJECT_TOKEN_TYPE, + credential_source=self.CREDENTIAL_SOURCE_TEXT_URL.copy(), + workforce_pool_user_project=WORKFORCE_POOL_USER_PROJECT, + ) + + assert credentials.info == { + "type": "external_account", + "audience": WORKFORCE_AUDIENCE, + "subject_token_type": WORKFORCE_SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "token_info_url": TOKEN_INFO_URL, + "credential_source": self.CREDENTIAL_SOURCE_TEXT_URL, + "workforce_pool_user_project": WORKFORCE_POOL_USER_PROJECT, + "universe_domain": DEFAULT_UNIVERSE_DOMAIN, + } + + def test_info_with_file_credential_source(self): + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE_TEXT_URL.copy() + ) + + assert credentials.info == { + "type": "external_account", + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "token_info_url": TOKEN_INFO_URL, + "credential_source": self.CREDENTIAL_SOURCE_TEXT_URL, + "universe_domain": DEFAULT_UNIVERSE_DOMAIN, + } + + def test_info_with_url_credential_source(self): + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE_JSON_URL.copy() + ) + + assert credentials.info == { + "type": "external_account", + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "token_info_url": TOKEN_INFO_URL, + "credential_source": self.CREDENTIAL_SOURCE_JSON_URL, + "universe_domain": DEFAULT_UNIVERSE_DOMAIN, + } + + def test_info_with_certificate_credential_source(self): + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE_CERTIFICATE.copy() + ) + + assert credentials.info == { + "type": "external_account", + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "token_info_url": TOKEN_INFO_URL, + "credential_source": self.CREDENTIAL_SOURCE_CERTIFICATE, + "universe_domain": DEFAULT_UNIVERSE_DOMAIN, + } + + def test_info_with_non_default_certificate_credential_source(self): + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE_CERTIFICATE_NOT_DEFAULT.copy() + ) + + assert credentials.info == { + "type": "external_account", + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "token_info_url": TOKEN_INFO_URL, + "credential_source": self.CREDENTIAL_SOURCE_CERTIFICATE_NOT_DEFAULT, + "universe_domain": DEFAULT_UNIVERSE_DOMAIN, + } + + def test_info_with_default_token_url(self): + credentials = identity_pool.Credentials( + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + credential_source=self.CREDENTIAL_SOURCE_TEXT_URL.copy(), + ) + + assert credentials.info == { + "type": "external_account", + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "credential_source": self.CREDENTIAL_SOURCE_TEXT_URL, + "universe_domain": DEFAULT_UNIVERSE_DOMAIN, + } + + def test_info_with_default_token_url_with_universe_domain(self): + credentials = identity_pool.Credentials( + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + credential_source=self.CREDENTIAL_SOURCE_TEXT_URL.copy(), + universe_domain="testdomain.org", + ) + + assert credentials.info == { + "type": "external_account", + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": "https://sts.testdomain.org/v1/token", + "credential_source": self.CREDENTIAL_SOURCE_TEXT_URL, + "universe_domain": "testdomain.org", + } + + def test_retrieve_subject_token_missing_subject_token(self, tmpdir): + # Provide empty text file. + empty_file = tmpdir.join("empty.txt") + empty_file.write("") + credential_source = {"file": str(empty_file)} + credentials = self.make_credentials(credential_source=credential_source) + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.retrieve_subject_token(None) + + assert excinfo.match(r"Missing subject_token in the credential_source file") + + def test_retrieve_subject_token_text_file(self): + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE_TEXT + ) + + subject_token = credentials.retrieve_subject_token(None) + + assert subject_token == TEXT_FILE_SUBJECT_TOKEN + + def test_retrieve_subject_token_json_file(self): + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE_JSON + ) + + subject_token = credentials.retrieve_subject_token(None) + + assert subject_token == JSON_FILE_SUBJECT_TOKEN + + @mock.patch( + "google.auth.transport._mtls_helper._get_workload_cert_and_key_paths", + return_value=(CERT_FILE, KEY_FILE), + ) + def test_retrieve_subject_token_certificate_default( + self, mock_get_workload_cert_and_key_paths + ): + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE_CERTIFICATE + ) + + subject_token = credentials.retrieve_subject_token(None) + + assert subject_token == json.dumps([CERT_FILE_CONTENT]) + + @mock.patch( + "google.auth.transport._mtls_helper._get_workload_cert_and_key_paths", + return_value=(CERT_FILE, KEY_FILE), + ) + def test_retrieve_subject_token_certificate_non_default_path( + self, mock_get_workload_cert_and_key_paths + ): + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE_CERTIFICATE_NOT_DEFAULT + ) + + subject_token = credentials.retrieve_subject_token(None) + + assert subject_token == json.dumps([CERT_FILE_CONTENT]) + + @mock.patch( + "google.auth.transport._mtls_helper._get_workload_cert_and_key_paths", + return_value=(CERT_FILE, KEY_FILE), + ) + def test_retrieve_subject_token_certificate_trust_chain_with_leaf( + self, mock_get_workload_cert_and_key_paths + ): + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE_CERTIFICATE_TRUST_CHAIN_WITH_LEAF + ) + + subject_token = credentials.retrieve_subject_token(None) + assert subject_token == json.dumps([CERT_FILE_CONTENT, OTHER_CERT_FILE_CONTENT]) + + @mock.patch( + "google.auth.transport._mtls_helper._get_workload_cert_and_key_paths", + return_value=(CERT_FILE, KEY_FILE), + ) + def test_retrieve_subject_token_certificate_trust_chain_without_leaf( + self, mock_get_workload_cert_and_key_paths + ): + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE_CERTIFICATE_TRUST_CHAIN_WITHOUT_LEAF + ) + + subject_token = credentials.retrieve_subject_token(None) + assert subject_token == json.dumps([CERT_FILE_CONTENT, OTHER_CERT_FILE_CONTENT]) + + @mock.patch( + "google.auth.transport._mtls_helper._get_workload_cert_and_key_paths", + return_value=(CERT_FILE, KEY_FILE), + ) + def test_retrieve_subject_token_certificate_trust_chain_invalid_order( + self, mock_get_workload_cert_and_key_paths + ): + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE_CERTIFICATE_TRUST_CHAIN_WRONG_ORDER + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.retrieve_subject_token(None) + + assert excinfo.match( + "The leaf certificate must be at the top of the trust chain file" + ) + + @mock.patch( + "google.auth.transport._mtls_helper._get_workload_cert_and_key_paths", + return_value=(CERT_FILE, KEY_FILE), + ) + def test_retrieve_subject_token_certificate_trust_chain_file_does_not_exist( + self, mock_get_workload_cert_and_key_paths + ): + credentials = self.make_credentials( + credential_source={ + "certificate": { + "use_default_certificate_config": "true", + "trust_chain_path": "fake.pem", + } + } + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.retrieve_subject_token(None) + + assert excinfo.match("Trust chain file 'fake.pem' was not found.") + + @mock.patch( + "google.auth.transport._mtls_helper._get_workload_cert_and_key_paths", + return_value=(CERT_FILE, KEY_FILE), + ) + def test_retrieve_subject_token_certificate_invalid_trust_chain_file( + self, mock_get_workload_cert_and_key_paths + ): + credentials = self.make_credentials( + credential_source={ + "certificate": { + "use_default_certificate_config": "true", + "trust_chain_path": SUBJECT_TOKEN_TEXT_FILE, + } + } + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.retrieve_subject_token(None) + + assert excinfo.match("Error loading PEM certificates from the trust chain file") + + def test_retrieve_subject_token_json_file_invalid_field_name(self): + credential_source = { + "file": SUBJECT_TOKEN_JSON_FILE, + "format": {"type": "json", "subject_token_field_name": "not_found"}, + } + credentials = self.make_credentials(credential_source=credential_source) + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.retrieve_subject_token(None) + + assert excinfo.match( + "Unable to parse subject_token from JSON file '{}' using key '{}'".format( + SUBJECT_TOKEN_JSON_FILE, "not_found" + ) + ) + + def test_retrieve_subject_token_invalid_json(self, tmpdir): + # Provide JSON file. This should result in JSON parsing error. + invalid_json_file = tmpdir.join("invalid.json") + invalid_json_file.write("{") + credential_source = { + "file": str(invalid_json_file), + "format": {"type": "json", "subject_token_field_name": "access_token"}, + } + credentials = self.make_credentials(credential_source=credential_source) + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.retrieve_subject_token(None) + + assert excinfo.match( + "Unable to parse subject_token from JSON file '{}' using key '{}'".format( + str(invalid_json_file), "access_token" + ) + ) + + def test_retrieve_subject_token_file_not_found(self): + credential_source = {"file": "./not_found.txt"} + credentials = self.make_credentials(credential_source=credential_source) + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.retrieve_subject_token(None) + + assert excinfo.match(r"File './not_found.txt' was not found") + + def test_token_info_url(self): + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE_JSON + ) + + assert credentials.token_info_url == TOKEN_INFO_URL + + def test_token_info_url_custom(self): + for url in VALID_TOKEN_URLS: + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE_JSON.copy(), + token_info_url=(url + "/introspect"), + ) + + assert credentials.token_info_url == url + "/introspect" + + def test_token_info_url_negative(self): + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE_JSON.copy(), token_info_url=None + ) + + assert not credentials.token_info_url + + def test_token_url_custom(self): + for url in VALID_TOKEN_URLS: + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE_JSON.copy(), + token_url=(url + "/token"), + ) + + assert credentials._token_url == (url + "/token") + + def test_service_account_impersonation_url_custom(self): + for url in VALID_SERVICE_ACCOUNT_IMPERSONATION_URLS: + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE_JSON.copy(), + service_account_impersonation_url=( + url + SERVICE_ACCOUNT_IMPERSONATION_URL_ROUTE + ), + ) + + assert credentials._service_account_impersonation_url == ( + url + SERVICE_ACCOUNT_IMPERSONATION_URL_ROUTE + ) + + def test_refresh_text_file_success_without_impersonation_ignore_default_scopes( + self, + ): + credentials = self.make_credentials( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + # Test with text format type. + credential_source=self.CREDENTIAL_SOURCE_TEXT, + scopes=SCOPES, + # Default scopes should be ignored. + default_scopes=["ignored"], + ) + + self.assert_underlying_credentials_refresh( + credentials=credentials, + audience=AUDIENCE, + subject_token=TEXT_FILE_SUBJECT_TOKEN, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + service_account_impersonation_url=None, + basic_auth_encoding=BASIC_AUTH_ENCODING, + quota_project_id=None, + used_scopes=SCOPES, + scopes=SCOPES, + default_scopes=["ignored"], + ) + + def test_refresh_workforce_success_with_client_auth_without_impersonation(self): + credentials = self.make_credentials( + audience=WORKFORCE_AUDIENCE, + subject_token_type=WORKFORCE_SUBJECT_TOKEN_TYPE, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + # Test with text format type. + credential_source=self.CREDENTIAL_SOURCE_TEXT, + scopes=SCOPES, + # This will be ignored in favor of client auth. + workforce_pool_user_project=WORKFORCE_POOL_USER_PROJECT, + ) + + self.assert_underlying_credentials_refresh( + credentials=credentials, + audience=WORKFORCE_AUDIENCE, + subject_token=TEXT_FILE_SUBJECT_TOKEN, + subject_token_type=WORKFORCE_SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + service_account_impersonation_url=None, + basic_auth_encoding=BASIC_AUTH_ENCODING, + quota_project_id=None, + used_scopes=SCOPES, + scopes=SCOPES, + workforce_pool_user_project=None, + ) + + def test_refresh_workforce_success_with_client_auth_and_no_workforce_project(self): + credentials = self.make_credentials( + audience=WORKFORCE_AUDIENCE, + subject_token_type=WORKFORCE_SUBJECT_TOKEN_TYPE, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + # Test with text format type. + credential_source=self.CREDENTIAL_SOURCE_TEXT, + scopes=SCOPES, + # This is not needed when client Auth is used. + workforce_pool_user_project=None, + ) + + self.assert_underlying_credentials_refresh( + credentials=credentials, + audience=WORKFORCE_AUDIENCE, + subject_token=TEXT_FILE_SUBJECT_TOKEN, + subject_token_type=WORKFORCE_SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + service_account_impersonation_url=None, + basic_auth_encoding=BASIC_AUTH_ENCODING, + quota_project_id=None, + used_scopes=SCOPES, + scopes=SCOPES, + workforce_pool_user_project=None, + ) + + def test_refresh_workforce_success_without_client_auth_without_impersonation(self): + credentials = self.make_credentials( + audience=WORKFORCE_AUDIENCE, + subject_token_type=WORKFORCE_SUBJECT_TOKEN_TYPE, + client_id=None, + client_secret=None, + # Test with text format type. + credential_source=self.CREDENTIAL_SOURCE_TEXT, + scopes=SCOPES, + # This will not be ignored as client auth is not used. + workforce_pool_user_project=WORKFORCE_POOL_USER_PROJECT, + ) + + self.assert_underlying_credentials_refresh( + credentials=credentials, + audience=WORKFORCE_AUDIENCE, + subject_token=TEXT_FILE_SUBJECT_TOKEN, + subject_token_type=WORKFORCE_SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + service_account_impersonation_url=None, + basic_auth_encoding=None, + quota_project_id=None, + used_scopes=SCOPES, + scopes=SCOPES, + workforce_pool_user_project=WORKFORCE_POOL_USER_PROJECT, + ) + + def test_refresh_workforce_success_without_client_auth_with_impersonation(self): + credentials = self.make_credentials( + audience=WORKFORCE_AUDIENCE, + subject_token_type=WORKFORCE_SUBJECT_TOKEN_TYPE, + client_id=None, + client_secret=None, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + # Test with text format type. + credential_source=self.CREDENTIAL_SOURCE_TEXT, + scopes=SCOPES, + # This will not be ignored as client auth is not used. + workforce_pool_user_project=WORKFORCE_POOL_USER_PROJECT, + ) + + self.assert_underlying_credentials_refresh( + credentials=credentials, + audience=WORKFORCE_AUDIENCE, + subject_token=TEXT_FILE_SUBJECT_TOKEN, + subject_token_type=WORKFORCE_SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + basic_auth_encoding=None, + quota_project_id=None, + used_scopes=SCOPES, + scopes=SCOPES, + workforce_pool_user_project=WORKFORCE_POOL_USER_PROJECT, + ) + + def test_refresh_text_file_success_without_impersonation_use_default_scopes(self): + credentials = self.make_credentials( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + # Test with text format type. + credential_source=self.CREDENTIAL_SOURCE_TEXT, + scopes=None, + # Default scopes should be used since user specified scopes are none. + default_scopes=SCOPES, + ) + + self.assert_underlying_credentials_refresh( + credentials=credentials, + audience=AUDIENCE, + subject_token=TEXT_FILE_SUBJECT_TOKEN, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + service_account_impersonation_url=None, + basic_auth_encoding=BASIC_AUTH_ENCODING, + quota_project_id=None, + used_scopes=SCOPES, + scopes=None, + default_scopes=SCOPES, + ) + + def test_refresh_text_file_success_with_impersonation_ignore_default_scopes(self): + # Initialize credentials with service account impersonation and basic auth. + credentials = self.make_credentials( + # Test with text format type. + credential_source=self.CREDENTIAL_SOURCE_TEXT, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + scopes=SCOPES, + # Default scopes should be ignored. + default_scopes=["ignored"], + ) + + self.assert_underlying_credentials_refresh( + credentials=credentials, + audience=AUDIENCE, + subject_token=TEXT_FILE_SUBJECT_TOKEN, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + basic_auth_encoding=None, + quota_project_id=None, + used_scopes=SCOPES, + scopes=SCOPES, + default_scopes=["ignored"], + ) + + def test_refresh_text_file_success_with_impersonation_use_default_scopes(self): + # Initialize credentials with service account impersonation, basic auth + # and default scopes (no user scopes). + credentials = self.make_credentials( + # Test with text format type. + credential_source=self.CREDENTIAL_SOURCE_TEXT, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + scopes=None, + # Default scopes should be used since user specified scopes are none. + default_scopes=SCOPES, + ) + + self.assert_underlying_credentials_refresh( + credentials=credentials, + audience=AUDIENCE, + subject_token=TEXT_FILE_SUBJECT_TOKEN, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + basic_auth_encoding=None, + quota_project_id=None, + used_scopes=SCOPES, + scopes=None, + default_scopes=SCOPES, + ) + + def test_refresh_json_file_success_without_impersonation(self): + credentials = self.make_credentials( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + # Test with JSON format type. + credential_source=self.CREDENTIAL_SOURCE_JSON, + scopes=SCOPES, + ) + + self.assert_underlying_credentials_refresh( + credentials=credentials, + audience=AUDIENCE, + subject_token=JSON_FILE_SUBJECT_TOKEN, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + service_account_impersonation_url=None, + basic_auth_encoding=BASIC_AUTH_ENCODING, + quota_project_id=None, + used_scopes=SCOPES, + scopes=SCOPES, + default_scopes=None, + ) + + def test_refresh_json_file_success_with_impersonation(self): + # Initialize credentials with service account impersonation and basic auth. + credentials = self.make_credentials( + # Test with JSON format type. + credential_source=self.CREDENTIAL_SOURCE_JSON, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + scopes=SCOPES, + ) + + self.assert_underlying_credentials_refresh( + credentials=credentials, + audience=AUDIENCE, + subject_token=JSON_FILE_SUBJECT_TOKEN, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + basic_auth_encoding=None, + quota_project_id=None, + used_scopes=SCOPES, + scopes=SCOPES, + default_scopes=None, + ) + + def test_refresh_with_retrieve_subject_token_error(self): + credential_source = { + "file": SUBJECT_TOKEN_JSON_FILE, + "format": {"type": "json", "subject_token_field_name": "not_found"}, + } + credentials = self.make_credentials(credential_source=credential_source) + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.refresh(None) + + assert excinfo.match( + "Unable to parse subject_token from JSON file '{}' using key '{}'".format( + SUBJECT_TOKEN_JSON_FILE, "not_found" + ) + ) + + def test_retrieve_subject_token_from_url(self): + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE_TEXT_URL + ) + request = self.make_mock_request(token_data=TEXT_FILE_SUBJECT_TOKEN) + subject_token = credentials.retrieve_subject_token(request) + + assert subject_token == TEXT_FILE_SUBJECT_TOKEN + self.assert_credential_request_kwargs(request.call_args_list[0][1], None) + + def test_retrieve_subject_token_from_url_with_headers(self): + credentials = self.make_credentials( + credential_source={"url": self.CREDENTIAL_URL, "headers": {"foo": "bar"}} + ) + request = self.make_mock_request(token_data=TEXT_FILE_SUBJECT_TOKEN) + subject_token = credentials.retrieve_subject_token(request) + + assert subject_token == TEXT_FILE_SUBJECT_TOKEN + self.assert_credential_request_kwargs( + request.call_args_list[0][1], {"foo": "bar"} + ) + + def test_retrieve_subject_token_from_url_json(self): + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE_JSON_URL + ) + request = self.make_mock_request(token_data=JSON_FILE_CONTENT) + subject_token = credentials.retrieve_subject_token(request) + + assert subject_token == JSON_FILE_SUBJECT_TOKEN + self.assert_credential_request_kwargs(request.call_args_list[0][1], None) + + def test_retrieve_subject_token_from_url_json_with_headers(self): + credentials = self.make_credentials( + credential_source={ + "url": self.CREDENTIAL_URL, + "format": {"type": "json", "subject_token_field_name": "access_token"}, + "headers": {"foo": "bar"}, + } + ) + request = self.make_mock_request(token_data=JSON_FILE_CONTENT) + subject_token = credentials.retrieve_subject_token(request) + + assert subject_token == JSON_FILE_SUBJECT_TOKEN + self.assert_credential_request_kwargs( + request.call_args_list[0][1], {"foo": "bar"} + ) + + def test_retrieve_subject_token_from_url_not_found(self): + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE_TEXT_URL + ) + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.retrieve_subject_token( + self.make_mock_request(token_status=404, token_data=JSON_FILE_CONTENT) + ) + + assert excinfo.match("Unable to retrieve Identity Pool subject token") + + def test_retrieve_subject_token_from_url_json_invalid_field(self): + credential_source = { + "url": self.CREDENTIAL_URL, + "format": {"type": "json", "subject_token_field_name": "not_found"}, + } + credentials = self.make_credentials(credential_source=credential_source) + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.retrieve_subject_token( + self.make_mock_request(token_data=JSON_FILE_CONTENT) + ) + + assert excinfo.match( + "Unable to parse subject_token from JSON file '{}' using key '{}'".format( + self.CREDENTIAL_URL, "not_found" + ) + ) + + def test_retrieve_subject_token_from_url_json_invalid_format(self): + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE_JSON_URL + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.retrieve_subject_token(self.make_mock_request(token_data="{")) + + assert excinfo.match( + "Unable to parse subject_token from JSON file '{}' using key '{}'".format( + self.CREDENTIAL_URL, "access_token" + ) + ) + + def test_refresh_text_file_success_without_impersonation_url(self): + credentials = self.make_credentials( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + # Test with text format type. + credential_source=self.CREDENTIAL_SOURCE_TEXT_URL, + scopes=SCOPES, + ) + + self.assert_underlying_credentials_refresh( + credentials=credentials, + audience=AUDIENCE, + subject_token=TEXT_FILE_SUBJECT_TOKEN, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + service_account_impersonation_url=None, + basic_auth_encoding=BASIC_AUTH_ENCODING, + quota_project_id=None, + used_scopes=SCOPES, + scopes=SCOPES, + default_scopes=None, + credential_data=TEXT_FILE_SUBJECT_TOKEN, + ) + + def test_refresh_text_file_success_with_impersonation_url(self): + # Initialize credentials with service account impersonation and basic auth. + credentials = self.make_credentials( + # Test with text format type. + credential_source=self.CREDENTIAL_SOURCE_TEXT_URL, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + scopes=SCOPES, + ) + + self.assert_underlying_credentials_refresh( + credentials=credentials, + audience=AUDIENCE, + subject_token=TEXT_FILE_SUBJECT_TOKEN, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + basic_auth_encoding=None, + quota_project_id=None, + used_scopes=SCOPES, + scopes=SCOPES, + default_scopes=None, + credential_data=TEXT_FILE_SUBJECT_TOKEN, + ) + + def test_refresh_json_file_success_without_impersonation_url(self): + credentials = self.make_credentials( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + # Test with JSON format type. + credential_source=self.CREDENTIAL_SOURCE_JSON_URL, + scopes=SCOPES, + ) + + self.assert_underlying_credentials_refresh( + credentials=credentials, + audience=AUDIENCE, + subject_token=JSON_FILE_SUBJECT_TOKEN, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + service_account_impersonation_url=None, + basic_auth_encoding=BASIC_AUTH_ENCODING, + quota_project_id=None, + used_scopes=SCOPES, + scopes=SCOPES, + default_scopes=None, + credential_data=JSON_FILE_CONTENT, + ) + + def test_refresh_json_file_success_with_impersonation_url(self): + # Initialize credentials with service account impersonation and basic auth. + credentials = self.make_credentials( + # Test with JSON format type. + credential_source=self.CREDENTIAL_SOURCE_JSON_URL, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + scopes=SCOPES, + ) + + self.assert_underlying_credentials_refresh( + credentials=credentials, + audience=AUDIENCE, + subject_token=JSON_FILE_SUBJECT_TOKEN, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + basic_auth_encoding=None, + quota_project_id=None, + used_scopes=SCOPES, + scopes=SCOPES, + default_scopes=None, + credential_data=JSON_FILE_CONTENT, + ) + + def test_refresh_with_retrieve_subject_token_error_url(self): + credential_source = { + "url": self.CREDENTIAL_URL, + "format": {"type": "json", "subject_token_field_name": "not_found"}, + } + credentials = self.make_credentials(credential_source=credential_source) + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.refresh(self.make_mock_request(token_data=JSON_FILE_CONTENT)) + + assert excinfo.match( + "Unable to parse subject_token from JSON file '{}' using key '{}'".format( + self.CREDENTIAL_URL, "not_found" + ) + ) + + def test_retrieve_subject_token_supplier(self): + supplier = TestSubjectTokenSupplier(subject_token=JSON_FILE_SUBJECT_TOKEN) + + credentials = self.make_credentials(subject_token_supplier=supplier) + + subject_token = credentials.retrieve_subject_token(None) + + assert subject_token == JSON_FILE_SUBJECT_TOKEN + + def test_retrieve_subject_token_supplier_correct_context(self): + supplier = TestSubjectTokenSupplier( + subject_token=JSON_FILE_SUBJECT_TOKEN, + expected_context=external_account.SupplierContext( + SUBJECT_TOKEN_TYPE, AUDIENCE + ), + ) + + credentials = self.make_credentials(subject_token_supplier=supplier) + + credentials.retrieve_subject_token(None) + + def test_retrieve_subject_token_supplier_error(self): + expected_exception = exceptions.RefreshError("test error") + supplier = TestSubjectTokenSupplier(subject_token_exception=expected_exception) + + credentials = self.make_credentials(subject_token_supplier=supplier) + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.refresh(self.make_mock_request(token_data=JSON_FILE_CONTENT)) + + assert excinfo.match("test error") + + def test_refresh_success_supplier_with_impersonation_url(self): + # Initialize credentials with service account impersonation and a supplier. + supplier = TestSubjectTokenSupplier(subject_token=JSON_FILE_SUBJECT_TOKEN) + credentials = self.make_credentials( + subject_token_supplier=supplier, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + scopes=SCOPES, + ) + + self.assert_underlying_credentials_refresh( + credentials=credentials, + audience=AUDIENCE, + subject_token=TEXT_FILE_SUBJECT_TOKEN, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + basic_auth_encoding=None, + quota_project_id=None, + used_scopes=SCOPES, + scopes=SCOPES, + default_scopes=None, + ) + + def test_refresh_success_supplier_without_impersonation_url(self): + # Initialize supplier credentials without service account impersonation. + supplier = TestSubjectTokenSupplier(subject_token=JSON_FILE_SUBJECT_TOKEN) + credentials = self.make_credentials( + subject_token_supplier=supplier, scopes=SCOPES + ) + + self.assert_underlying_credentials_refresh( + credentials=credentials, + audience=AUDIENCE, + subject_token=TEXT_FILE_SUBJECT_TOKEN, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + basic_auth_encoding=None, + quota_project_id=None, + used_scopes=SCOPES, + scopes=SCOPES, + default_scopes=None, + ) + + @mock.patch( + "google.auth.transport._mtls_helper._get_workload_cert_and_key_paths", + return_value=("cert", "key"), + ) + def test_get_mtls_certs(self, mock_get_workload_cert_and_key_paths): + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE_CERTIFICATE.copy() + ) + + cert, key = credentials._get_mtls_cert_and_key_paths() + assert cert == "cert" + assert key == "key" + + def test_get_mtls_certs_invalid(self): + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE_TEXT.copy() + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials._get_mtls_cert_and_key_paths() + + assert excinfo.match( + 'The credential is not configured to use mtls requests. The credential should include a "certificate" section in the credential source.' + ) diff --git a/packages/google-auth/tests/test_impersonated_credentials.py b/packages/google-auth/tests/test_impersonated_credentials.py new file mode 100644 index 000000000000..ab92e0bd9297 --- /dev/null +++ b/packages/google-auth/tests/test_impersonated_credentials.py @@ -0,0 +1,1288 @@ +# Copyright 2018 Google Inc. +# +# 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 copy +import datetime +import http.client as http_client +import json +import os + +import mock +import pytest # type: ignore + +from google.auth import _helpers +from google.auth import credentials as auth_credentials +from google.auth import crypt +from google.auth import environment_vars +from google.auth import exceptions +from google.auth import impersonated_credentials +from google.auth import transport +from google.auth.impersonated_credentials import Credentials +from google.oauth2 import credentials +from google.oauth2 import service_account + +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() + +SERVICE_ACCOUNT_JSON_FILE = os.path.join(DATA_DIR, "service_account.json") +IMPERSONATED_SERVICE_ACCOUNT_AUTHORIZED_USER_SOURCE_FILE = os.path.join( + DATA_DIR, "impersonated_service_account_authorized_user_source.json" +) + +ID_TOKEN_DATA = ( + "eyJhbGciOiJSUzI1NiIsImtpZCI6ImRmMzc1ODkwOGI3OTIyOTNhZDk3N2Ew" + "Yjk5MWQ5OGE3N2Y0ZWVlY2QiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOiJodHRwc" + "zovL2Zvby5iYXIiLCJhenAiOiIxMDIxMDE1NTA4MzQyMDA3MDg1NjgiLCJle" + "HAiOjE1NjQ0NzUwNTEsImlhdCI6MTU2NDQ3MTQ1MSwiaXNzIjoiaHR0cHM6L" + "y9hY2NvdW50cy5nb29nbGUuY29tIiwic3ViIjoiMTAyMTAxNTUwODM0MjAwN" + "zA4NTY4In0.redacted" +) +ID_TOKEN_EXPIRY = 1564475051 + +with open(SERVICE_ACCOUNT_JSON_FILE, "rb") as fh: + SERVICE_ACCOUNT_INFO = json.load(fh) + +with open(IMPERSONATED_SERVICE_ACCOUNT_AUTHORIZED_USER_SOURCE_FILE, "rb") as fh: + IMPERSONATED_SERVICE_ACCOUNT_AUTHORIZED_USER_SOURCE_INFO = json.load(fh) + +SIGNER = crypt.RSASigner.from_string(PRIVATE_KEY_BYTES, "1") +TOKEN_URI = "https://example.com/oauth2/token" + +ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE = ( + "gl-python/3.7 auth/1.1 auth-request-type/at cred-type/imp" +) +ID_TOKEN_REQUEST_METRICS_HEADER_VALUE = ( + "gl-python/3.7 auth/1.1 auth-request-type/it cred-type/imp" +) + + +@pytest.fixture +def mock_donor_credentials(): + with mock.patch("google.oauth2._client.jwt_grant", autospec=True) as grant: + grant.return_value = ( + "source token", + _helpers.utcnow() + datetime.timedelta(seconds=500), + {}, + ) + yield grant + + +@pytest.fixture +def mock_dwd_credentials(): + with mock.patch("google.oauth2._client.jwt_grant", autospec=True) as grant: + grant.return_value = ( + "1/fFAGRNJasdfz70BzhT3Zg", + _helpers.utcnow() + datetime.timedelta(seconds=500), + {}, + ) + yield grant + + +class MockResponse: + def __init__(self, json_data, status_code): + self.json_data = json_data + self.status_code = status_code + + def json(self): + return self.json_data + + +@pytest.fixture +def mock_authorizedsession_sign(): + with mock.patch( + "google.auth.transport.requests.AuthorizedSession.request", autospec=True + ) as auth_session: + data = {"keyId": "1", "signedBlob": "c2lnbmF0dXJl"} + auth_session.return_value = MockResponse(data, http_client.OK) + yield auth_session + + +@pytest.fixture +def mock_authorizedsession_idtoken(): + with mock.patch( + "google.auth.transport.requests.AuthorizedSession.request", autospec=True + ) as auth_session: + data = {"token": ID_TOKEN_DATA} + auth_session.return_value = MockResponse(data, http_client.OK) + yield auth_session + + +class TestImpersonatedCredentials(object): + SERVICE_ACCOUNT_EMAIL = "service-account@example.com" + TARGET_PRINCIPAL = "impersonated@project.iam.gserviceaccount.com" + TARGET_SCOPES = ["https://www.googleapis.com/auth/devstorage.read_only"] + # DELEGATES: List[str] = [] + # Because Python 2.7: + DELEGATES = [] # type: ignore + LIFETIME = 3600 + NO_OP_TRUST_BOUNDARY = { + "locations": auth_credentials.NO_OP_TRUST_BOUNDARY_LOCATIONS, + "encodedLocations": auth_credentials.NO_OP_TRUST_BOUNDARY_ENCODED_LOCATIONS, + } + VALID_TRUST_BOUNDARY = { + "locations": ["us-central1", "us-east1"], + "encodedLocations": "0xVALIDHEX", + } + EXPECTED_TRUST_BOUNDARY_LOOKUP_URL_DEFAULT_UNIVERSE = ( + "https://iamcredentials.googleapis.com/v1/projects/-" + "/serviceAccounts/impersonated@project.iam.gserviceaccount.com/allowedLocations" + ) + FAKE_UNIVERSE_DOMAIN = "universe.foo" + SOURCE_CREDENTIALS = service_account.Credentials( + SIGNER, SERVICE_ACCOUNT_EMAIL, TOKEN_URI, trust_boundary=NO_OP_TRUST_BOUNDARY + ) + USER_SOURCE_CREDENTIALS = credentials.Credentials(token="ABCDE") + IAM_ENDPOINT_OVERRIDE = ( + "https://us-east1-iamcredentials.googleapis.com/v1/projects/-" + + "/serviceAccounts/{}:generateAccessToken".format(SERVICE_ACCOUNT_EMAIL) + ) + + def make_credentials( + self, + source_credentials=SOURCE_CREDENTIALS, + lifetime=LIFETIME, + target_principal=TARGET_PRINCIPAL, + subject=None, + iam_endpoint_override=None, + trust_boundary=None, # Align with Credentials class default + ): + return Credentials( + source_credentials=source_credentials, + target_principal=target_principal, + target_scopes=self.TARGET_SCOPES, + delegates=self.DELEGATES, + lifetime=lifetime, + subject=subject, + iam_endpoint_override=iam_endpoint_override, + trust_boundary=trust_boundary, + ) + + def test_from_impersonated_service_account_info(self): + credentials = ( + impersonated_credentials.Credentials.from_impersonated_service_account_info( + IMPERSONATED_SERVICE_ACCOUNT_AUTHORIZED_USER_SOURCE_INFO + ) + ) + assert isinstance(credentials, impersonated_credentials.Credentials) + + def test_from_impersonated_service_account_info_with_trust_boundary(self): + info = copy.deepcopy(IMPERSONATED_SERVICE_ACCOUNT_AUTHORIZED_USER_SOURCE_INFO) + info["trust_boundary"] = self.VALID_TRUST_BOUNDARY + credentials = ( + impersonated_credentials.Credentials.from_impersonated_service_account_info( + info + ) + ) + assert isinstance(credentials, impersonated_credentials.Credentials) + assert credentials._trust_boundary == self.VALID_TRUST_BOUNDARY + + def test_from_impersonated_service_account_info_with_invalid_source_credentials_type( + self, + ): + info = copy.deepcopy(IMPERSONATED_SERVICE_ACCOUNT_AUTHORIZED_USER_SOURCE_INFO) + assert "source_credentials" in info + # Set the source_credentials to an invalid type + info["source_credentials"]["type"] = "invalid_type" + with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: + impersonated_credentials.Credentials.from_impersonated_service_account_info( + info + ) + assert excinfo.match( + "source credential of type {} is not supported".format("invalid_type") + ) + + def test_from_impersonated_service_account_info_with_invalid_impersonation_url( + self, + ): + info = copy.deepcopy(IMPERSONATED_SERVICE_ACCOUNT_AUTHORIZED_USER_SOURCE_INFO) + info["service_account_impersonation_url"] = "invalid_url" + with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: + impersonated_credentials.Credentials.from_impersonated_service_account_info( + info + ) + assert excinfo.match(r"Cannot extract target principal from") + + def test_from_impersonated_service_account_info_with_scopes(self): + info = copy.deepcopy(IMPERSONATED_SERVICE_ACCOUNT_AUTHORIZED_USER_SOURCE_INFO) + info["scopes"] = ["scope1", "scope2"] + credentials = ( + impersonated_credentials.Credentials.from_impersonated_service_account_info( + info + ) + ) + assert credentials._target_scopes == ["scope1", "scope2"] + + def test_from_impersonated_service_account_info_with_scopes_param(self): + info = copy.deepcopy(IMPERSONATED_SERVICE_ACCOUNT_AUTHORIZED_USER_SOURCE_INFO) + info["scopes"] = ["scope_from_info_1", "scope_from_info_2"] + scopes_param = ["scope_from_param_1", "scope_from_param_2"] + credentials = ( + impersonated_credentials.Credentials.from_impersonated_service_account_info( + info, scopes=scopes_param + ) + ) + assert credentials._target_scopes == scopes_param + + def test_get_cred_info(self): + credentials = self.make_credentials() + assert not credentials.get_cred_info() + + credentials._cred_file_path = "/path/to/file" + assert credentials.get_cred_info() == { + "credential_source": "/path/to/file", + "credential_type": "impersonated credentials", + "principal": "impersonated@project.iam.gserviceaccount.com", + } + + def test_universe_domain_matching_source(self): + source_credentials = service_account.Credentials( + SIGNER, "some@email.com", TOKEN_URI, universe_domain="foo.bar" + ) + credentials = self.make_credentials(source_credentials=source_credentials) + assert credentials.universe_domain == "foo.bar" + + def test__make_copy_get_cred_info(self): + credentials = self.make_credentials() + credentials._cred_file_path = "/path/to/file" + cred_copy = credentials._make_copy() + assert cred_copy._cred_file_path == "/path/to/file" + + def test_make_from_user_credentials(self): + credentials = self.make_credentials( + source_credentials=self.USER_SOURCE_CREDENTIALS + ) + assert not credentials.valid + assert credentials.expired + + def test_default_state(self): + credentials = self.make_credentials() + assert not credentials.valid + assert credentials.expired + + def test_make_from_service_account_self_signed_jwt(self): + source_credentials = service_account.Credentials( + SIGNER, self.SERVICE_ACCOUNT_EMAIL, TOKEN_URI, always_use_jwt_access=True + ) + credentials = self.make_credentials(source_credentials=source_credentials) + # test the source credential don't lose self signed jwt setting + assert credentials._source_credentials._always_use_jwt_access + assert credentials._source_credentials._jwt_credentials + + def make_request( + self, + data, + status=http_client.OK, + headers=None, + side_effect=None, + use_data_bytes=True, + ): + response = mock.create_autospec(transport.Response, instance=False) + response.status = status + response.data = _helpers.to_bytes(data) if use_data_bytes else data + response.headers = headers or {} + + request = mock.create_autospec(transport.Request, instance=False) + request.side_effect = side_effect + request.return_value = response + + return request + + def test_token_usage_metrics(self): + credentials = self.make_credentials() + credentials.token = "token" + credentials.expiry = None + + headers = {} + credentials.before_request(mock.Mock(), None, None, headers) + assert headers["authorization"] == "Bearer token" + assert headers["x-goog-api-client"] == "cred-type/imp" + + @pytest.mark.parametrize("use_data_bytes", [True, False]) + @mock.patch("google.oauth2._client._lookup_trust_boundary") + def test_refresh_success( + self, mock_lookup_trust_boundary, use_data_bytes, mock_donor_credentials + ): + # Start with no boundary. + credentials = self.make_credentials(lifetime=None, trust_boundary=None) + token = "token" + + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=500) + ).isoformat("T") + "Z" + response_body = {"accessToken": token, "expireTime": expire_time} + + request = self.make_request( + data=json.dumps(response_body), + status=http_client.OK, + use_data_bytes=use_data_bytes, + ) + + # Mock the trust boundary lookup to return a valid value. + mock_lookup_trust_boundary.return_value = self.VALID_TRUST_BOUNDARY + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + ), mock.patch( + "google.auth.metrics.token_request_access_token_impersonate", + return_value=ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, + ): + credentials.refresh(request) + + assert credentials.valid + assert not credentials.expired + assert ( + request.call_args.kwargs["headers"]["x-goog-api-client"] + == ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE + ) + + # Verify that the x-allowed-locations header from the source credential + # was applied. The source credential has a NO_OP boundary, so the + # header should be an empty string. + request_kwargs = request.call_args[1] + assert "headers" in request_kwargs + assert "x-allowed-locations" in request_kwargs["headers"] + assert request_kwargs["headers"]["x-allowed-locations"] == "" + + # Verify trust boundary was set. + assert credentials._trust_boundary == self.VALID_TRUST_BOUNDARY + + # Verify the mock was called with the correct URL. + mock_lookup_trust_boundary.assert_called_once_with( + request, + self.EXPECTED_TRUST_BOUNDARY_LOOKUP_URL_DEFAULT_UNIVERSE, + headers={"authorization": "Bearer token"}, + ) + + # Verify x-allowed-locations header is set correctly by apply(). + headers_applied = {} + credentials.apply(headers_applied) + assert ( + headers_applied["x-allowed-locations"] + == self.VALID_TRUST_BOUNDARY["encodedLocations"] + ) + + def test_refresh_source_creds_no_trust_boundary(self): + # Use a source credential that does not support trust boundaries. + source_credentials = credentials.Credentials(token="source_token") + creds = self.make_credentials(source_credentials=source_credentials) + token = "impersonated_token" + + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=500) + ).isoformat("T") + "Z" + response_body = {"accessToken": token, "expireTime": expire_time} + + request = self.make_request( + data=json.dumps(response_body), status=http_client.OK + ) + + creds.refresh(request) + + # Verify that the x-allowed-locations header was NOT applied because + # the source credential does not support trust boundaries. + request_kwargs = request.call_args[1] + assert "x-allowed-locations" not in request_kwargs["headers"] + + @mock.patch("google.oauth2._client._lookup_trust_boundary") + def test_refresh_trust_boundary_lookup_fails_no_cache( + self, mock_lookup_trust_boundary, mock_donor_credentials + ): + # Start with no trust boundary + credentials = self.make_credentials(lifetime=None, trust_boundary=None) + token = "token" + + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=500) + ).isoformat("T") + "Z" + response_body = {"accessToken": token, "expireTime": expire_time} + + request = self.make_request( + data=json.dumps(response_body), status=http_client.OK + ) + + # Mock the trust boundary lookup to raise an error + mock_lookup_trust_boundary.side_effect = exceptions.RefreshError( + "Lookup failed" + ) + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + ), pytest.raises(exceptions.RefreshError) as excinfo: + credentials.refresh(request) + + assert "Lookup failed" in str(excinfo.value) + assert credentials._trust_boundary is None # Still no trust boundary + mock_lookup_trust_boundary.assert_called_once() + + @mock.patch("google.oauth2._client._lookup_trust_boundary") + def test_refresh_fetches_no_op_trust_boundary( + self, mock_lookup_trust_boundary, mock_donor_credentials + ): + # Start with no trust boundary + credentials = self.make_credentials(lifetime=None, trust_boundary=None) + token = "token" + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=500) + ).isoformat("T") + "Z" + response_body = {"accessToken": token, "expireTime": expire_time} + request = self.make_request( + data=json.dumps(response_body), status=http_client.OK + ) + + mock_lookup_trust_boundary.return_value = ( + self.NO_OP_TRUST_BOUNDARY + ) # Mock returns NO_OP + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + ), mock.patch( + "google.auth.metrics.token_request_access_token_impersonate", + return_value=ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, + ): + credentials.refresh(request) + + assert credentials._trust_boundary == self.NO_OP_TRUST_BOUNDARY + mock_lookup_trust_boundary.assert_called_once_with( + request, + self.EXPECTED_TRUST_BOUNDARY_LOOKUP_URL_DEFAULT_UNIVERSE, + headers={"authorization": "Bearer token"}, + ) + headers_applied = {} + credentials.apply(headers_applied) + assert headers_applied["x-allowed-locations"] == "" + + @mock.patch("google.oauth2._client._lookup_trust_boundary") + def test_refresh_skips_trust_boundary_lookup_non_default_universe( + self, mock_lookup_trust_boundary + ): + # Create source credentials with a non-default universe domain + source_credentials = service_account.Credentials( + SIGNER, + "some@email.com", + TOKEN_URI, + universe_domain=self.FAKE_UNIVERSE_DOMAIN, + ) + # Create impersonated credentials using the non-default source credentials + credentials = self.make_credentials(source_credentials=source_credentials) + + # Mock the IAM credentials API call for generateAccessToken + token = "token" + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=500) + ).isoformat("T") + "Z" + response_body = {"accessToken": token, "expireTime": expire_time} + request = self.make_request( + data=json.dumps(response_body), status=http_client.OK + ) + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + ): + credentials.refresh(request) + + # Ensure trust boundary lookup was not called + mock_lookup_trust_boundary.assert_not_called() + # Verify that x-allowed-locations header is not set by apply() + headers_applied = {} + credentials.apply(headers_applied) + assert "x-allowed-locations" not in headers_applied + + @mock.patch("google.oauth2._client._lookup_trust_boundary") + def test_refresh_starts_with_no_op_trust_boundary_skips_lookup( + self, mock_lookup_trust_boundary, mock_donor_credentials + ): + credentials = self.make_credentials( + lifetime=None, trust_boundary=self.NO_OP_TRUST_BOUNDARY + ) # Start with NO_OP + token = "token" + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=500) + ).isoformat("T") + "Z" + response_body = {"accessToken": token, "expireTime": expire_time} + request = self.make_request( + data=json.dumps(response_body), status=http_client.OK + ) + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + ), mock.patch( + "google.auth.metrics.token_request_access_token_impersonate", + return_value=ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, + ): + credentials.refresh(request) + + # Verify trust boundary remained NO_OP + assert credentials._trust_boundary == self.NO_OP_TRUST_BOUNDARY + + # Lookup should be skipped + mock_lookup_trust_boundary.assert_not_called() + + # Verify that an empty header was added. + headers_applied = {} + credentials.apply(headers_applied) + assert headers_applied["x-allowed-locations"] == "" + + @mock.patch("google.oauth2._client._lookup_trust_boundary") + def test_refresh_trust_boundary_lookup_fails_with_cached_data2( + self, mock_lookup_trust_boundary, mock_donor_credentials + ): + # Start with no trust boundary + credentials = self.make_credentials(lifetime=None, trust_boundary=None) + token = "token" + + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=500) + ).isoformat("T") + "Z" + response_body = {"accessToken": token, "expireTime": expire_time} + + request = self.make_request( + data=json.dumps(response_body), status=http_client.OK + ) + + # First refresh: Successfully fetch a valid trust boundary. + mock_lookup_trust_boundary.return_value = self.VALID_TRUST_BOUNDARY + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + ), mock.patch( + "google.auth.metrics.token_request_access_token_impersonate", + return_value=ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, + ): + credentials.refresh(request) + + assert credentials.valid + # Verify trust boundary was set. + assert credentials._trust_boundary == self.VALID_TRUST_BOUNDARY + mock_lookup_trust_boundary.assert_called_once() + + # Second refresh: Mock lookup to fail, but expect cached data to be preserved. + mock_lookup_trust_boundary.reset_mock() + mock_lookup_trust_boundary.side_effect = exceptions.RefreshError( + "Lookup failed" + ) + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + ): + credentials.refresh(request) + + assert credentials.valid + assert credentials._trust_boundary == self.VALID_TRUST_BOUNDARY + mock_lookup_trust_boundary.assert_called_once() + + @pytest.mark.parametrize("use_data_bytes", [True, False]) + def test_refresh_with_subject_success(self, use_data_bytes, mock_dwd_credentials): + credentials = self.make_credentials(subject="test@email.com", lifetime=None) + + response_body = {"signedJwt": "example_signed_jwt"} + + request = self.make_request( + data=json.dumps(response_body), + status=http_client.OK, + use_data_bytes=use_data_bytes, + ) + + with mock.patch( + "google.auth.metrics.token_request_access_token_impersonate", + return_value=ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, + ): + credentials.refresh(request) + + assert credentials.valid + assert not credentials.expired + assert credentials.token == "1/fFAGRNJasdfz70BzhT3Zg" + + @pytest.mark.parametrize("use_data_bytes", [True, False]) + def test_refresh_success_nonGdu(self, use_data_bytes, mock_donor_credentials): + source_credentials = service_account.Credentials( + SIGNER, "some@email.com", TOKEN_URI, universe_domain="foo.bar" + ) + credentials = self.make_credentials( + lifetime=None, source_credentials=source_credentials + ) + token = "token" + + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=500) + ).isoformat("T") + "Z" + response_body = {"accessToken": token, "expireTime": expire_time} + + request = self.make_request( + data=json.dumps(response_body), + status=http_client.OK, + use_data_bytes=use_data_bytes, + ) + + credentials.refresh(request) + + assert credentials.valid + assert not credentials.expired + # Confirm override endpoint used. + request_kwargs = request.call_args[1] + assert ( + request_kwargs["url"] + == "https://iamcredentials.foo.bar/v1/projects/-/serviceAccounts/impersonated@project.iam.gserviceaccount.com:generateAccessToken" + ) + + @pytest.mark.parametrize("use_data_bytes", [True, False]) + def test_refresh_success_iam_endpoint_override( + self, use_data_bytes, mock_donor_credentials + ): + credentials = self.make_credentials( + lifetime=None, iam_endpoint_override=self.IAM_ENDPOINT_OVERRIDE + ) + token = "token" + + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=500) + ).isoformat("T") + "Z" + response_body = {"accessToken": token, "expireTime": expire_time} + + request = self.make_request( + data=json.dumps(response_body), + status=http_client.OK, + use_data_bytes=use_data_bytes, + ) + + credentials.refresh(request) + + assert credentials.valid + assert not credentials.expired + # Confirm override endpoint used. + request_kwargs = request.call_args[1] + assert request_kwargs["url"] == self.IAM_ENDPOINT_OVERRIDE + + @pytest.mark.parametrize("time_skew", [150, -150]) + def test_refresh_source_credentials(self, time_skew): + credentials = self.make_credentials(lifetime=None) + + # Source credentials is refreshed only if it is expired within + # _helpers.REFRESH_THRESHOLD from now. We add a time_skew to the expiry, so + # source credentials is refreshed only if time_skew <= 0. + credentials._source_credentials.expiry = ( + _helpers.utcnow() + + _helpers.REFRESH_THRESHOLD + + datetime.timedelta(seconds=time_skew) + ) + credentials._source_credentials.token = "Token" + + with mock.patch( + "google.oauth2.service_account.Credentials._refresh_token", autospec=True + ) as source_cred_refresh_token: + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + + datetime.timedelta(seconds=500) + ).isoformat("T") + "Z" + response_body = {"accessToken": "token", "expireTime": expire_time} + request = self.make_request( + data=json.dumps(response_body), status=http_client.OK + ) + + credentials.refresh(request) + + if time_skew <= 0: + source_cred_refresh_token.assert_called_once() + else: + source_cred_refresh_token.assert_not_called() + + def test_refresh_failure_malformed_expire_time(self, mock_donor_credentials): + credentials = self.make_credentials(lifetime=None) + token = "token" + + expire_time = (_helpers.utcnow() + datetime.timedelta(seconds=500)).isoformat( + "T" + ) + response_body = {"accessToken": token, "expireTime": expire_time} + + request = self.make_request( + data=json.dumps(response_body), status=http_client.OK + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.refresh(request) + + assert excinfo.match(impersonated_credentials._REFRESH_ERROR) + + assert not credentials.valid + assert credentials.expired + + def test_refresh_failure_unauthorzed(self, mock_donor_credentials): + credentials = self.make_credentials(lifetime=None) + + response_body = { + "error": { + "code": 403, + "message": "The caller does not have permission", + "status": "PERMISSION_DENIED", + } + } + + request = self.make_request( + data=json.dumps(response_body), status=http_client.UNAUTHORIZED + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.refresh(request) + + assert excinfo.match(impersonated_credentials._REFRESH_ERROR) + + assert not credentials.valid + assert credentials.expired + + def test_refresh_failure(self): + credentials = self.make_credentials(lifetime=None) + credentials.expiry = None + credentials.token = "token" + id_creds = impersonated_credentials.IDTokenCredentials( + credentials, target_audience="audience" + ) + + response = mock.create_autospec(transport.Response, instance=False) + response.status_code = http_client.UNAUTHORIZED + response.json = mock.Mock(return_value="failed to get ID token") + + with mock.patch( + "google.auth.transport.requests.AuthorizedSession.post", + return_value=response, + ): + with pytest.raises(exceptions.RefreshError) as excinfo: + id_creds.refresh(None) + + assert excinfo.match("Error getting ID token") + + def test_refresh_failure_http_error(self, mock_donor_credentials): + credentials = self.make_credentials(lifetime=None) + + response_body = {} + + request = self.make_request( + data=json.dumps(response_body), status=http_client.HTTPException + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.refresh(request) + + assert excinfo.match(impersonated_credentials._REFRESH_ERROR) + + assert not credentials.valid + assert credentials.expired + + def test_refresh_failure_subject_with_nondefault_domain( + self, mock_donor_credentials + ): + source_credentials = service_account.Credentials( + SIGNER, "some@email.com", TOKEN_URI, universe_domain="foo.bar" + ) + credentials = self.make_credentials( + source_credentials=source_credentials, subject="test@email.com" + ) + + expire_time = (_helpers.utcnow().replace(microsecond=0)).isoformat("T") + "Z" + response_body = {"accessToken": "token", "expireTime": expire_time} + request = self.make_request( + data=json.dumps(response_body), status=http_client.OK + ) + + with pytest.raises(exceptions.GoogleAuthError) as excinfo: + credentials.refresh(request) + + assert excinfo.match( + "Domain-wide delegation is not supported in universes other " + + "than googleapis.com" + ) + + assert not credentials.valid + assert credentials.expired + + def test_expired(self): + credentials = self.make_credentials(lifetime=None) + assert credentials.expired + + def test_signer(self): + credentials = self.make_credentials() + assert isinstance(credentials.signer, impersonated_credentials.Credentials) + + def test_signer_email(self): + credentials = self.make_credentials(target_principal=self.TARGET_PRINCIPAL) + assert credentials.signer_email == self.TARGET_PRINCIPAL + + def test_service_account_email(self): + credentials = self.make_credentials(target_principal=self.TARGET_PRINCIPAL) + assert credentials.service_account_email == self.TARGET_PRINCIPAL + + def test_sign_bytes(self, mock_donor_credentials, mock_authorizedsession_sign): + credentials = self.make_credentials(lifetime=None) + expected_url = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/impersonated@project.iam.gserviceaccount.com:signBlob" + self._sign_bytes_helper( + credentials, + mock_donor_credentials, + mock_authorizedsession_sign, + expected_url, + ) + + def test_sign_bytes_nonGdu( + self, mock_donor_credentials, mock_authorizedsession_sign + ): + source_credentials = service_account.Credentials( + SIGNER, "some@email.com", TOKEN_URI, universe_domain="foo.bar" + ) + credentials = self.make_credentials( + lifetime=None, source_credentials=source_credentials + ) + expected_url = "https://iamcredentials.foo.bar/v1/projects/-/serviceAccounts/impersonated@project.iam.gserviceaccount.com:signBlob" + self._sign_bytes_helper( + credentials, + mock_donor_credentials, + mock_authorizedsession_sign, + expected_url, + ) + + def _sign_bytes_helper( + self, + credentials, + mock_donor_credentials, + mock_authorizedsession_sign, + expected_url, + ): + token = "token" + + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=500) + ).isoformat("T") + "Z" + token_response_body = {"accessToken": token, "expireTime": expire_time} + + response = mock.create_autospec(transport.Response, instance=False) + response.status = http_client.OK + response.data = _helpers.to_bytes(json.dumps(token_response_body)) + + request = mock.create_autospec(transport.Request, instance=False) + request.return_value = response + + credentials.refresh(request) + assert credentials.valid + assert not credentials.expired + + signature = credentials.sign_bytes(b"signed bytes") + mock_authorizedsession_sign.assert_called_with( + mock.ANY, + "POST", + expected_url, + None, + json={"payload": "c2lnbmVkIGJ5dGVz", "delegates": []}, + headers={"Content-Type": "application/json"}, + ) + + assert signature == b"signature" + + def test_sign_bytes_failure(self): + credentials = self.make_credentials(lifetime=None) + + with mock.patch( + "google.auth.transport.requests.AuthorizedSession.request", autospec=True + ) as auth_session: + data = {"error": {"code": 403, "message": "unauthorized"}} + mock_response = MockResponse(data, http_client.UNAUTHORIZED) + auth_session.return_value = mock_response + + with pytest.raises(exceptions.TransportError) as excinfo: + credentials.sign_bytes(b"foo") + assert excinfo.match("'code': 403") + + @mock.patch("time.sleep", return_value=None) + def test_sign_bytes_retryable_failure(self, mock_time): + credentials = self.make_credentials(lifetime=None) + + with mock.patch( + "google.auth.transport.requests.AuthorizedSession.request", autospec=True + ) as auth_session: + data = {"error": {"code": 500, "message": "internal_failure"}} + mock_response = MockResponse(data, http_client.INTERNAL_SERVER_ERROR) + auth_session.return_value = mock_response + + with pytest.raises(exceptions.TransportError) as excinfo: + credentials.sign_bytes(b"foo") + assert excinfo.match("exhausted signBlob endpoint retries") + + def test_with_quota_project(self): + credentials = self.make_credentials() + + quota_project_creds = credentials.with_quota_project("project-foo") + assert quota_project_creds._quota_project_id == "project-foo" + + @pytest.mark.parametrize("use_data_bytes", [True, False]) + def test_with_quota_project_iam_endpoint_override( + self, use_data_bytes, mock_donor_credentials + ): + credentials = self.make_credentials( + lifetime=None, iam_endpoint_override=self.IAM_ENDPOINT_OVERRIDE + ) + token = "token" + # iam_endpoint_override should be copied to created credentials. + quota_project_creds = credentials.with_quota_project("project-foo") + + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=500) + ).isoformat("T") + "Z" + response_body = {"accessToken": token, "expireTime": expire_time} + + request = self.make_request( + data=json.dumps(response_body), + status=http_client.OK, + use_data_bytes=use_data_bytes, + ) + + quota_project_creds.refresh(request) + + assert quota_project_creds.valid + assert not quota_project_creds.expired + # Confirm override endpoint used. + request_kwargs = request.call_args[1] + assert request_kwargs["url"] == self.IAM_ENDPOINT_OVERRIDE + + def test_with_scopes(self): + credentials = self.make_credentials() + credentials._target_scopes = [] + assert credentials.requires_scopes is True + credentials = credentials.with_scopes(["fake_scope1", "fake_scope2"]) + assert credentials.requires_scopes is False + assert credentials._target_scopes == ["fake_scope1", "fake_scope2"] + + def test_with_trust_boundary(self): + credentials = self.make_credentials() + new_boundary = {"encodedLocations": "new_boundary"} + new_credentials = credentials.with_trust_boundary(new_boundary) + + assert new_credentials is not credentials + assert new_credentials._trust_boundary == new_boundary + # The source credentials should be a copy, not the same object. + # But they should be functionally equivalent. + assert ( + new_credentials._source_credentials is not credentials._source_credentials + ) + + assert ( + new_credentials._source_credentials.service_account_email + == credentials._source_credentials.service_account_email + ) + assert ( + new_credentials._source_credentials._signer + == credentials._source_credentials._signer + ) + assert new_credentials._target_principal == credentials._target_principal + + def test_build_trust_boundary_lookup_url_no_email(self): + credentials = self.make_credentials(target_principal=None) + + with pytest.raises(ValueError) as excinfo: + credentials._build_trust_boundary_lookup_url() + + assert "Service account email is required" in str(excinfo.value) + + def test_with_scopes_provide_default_scopes(self): + credentials = self.make_credentials() + credentials._target_scopes = [] + credentials = credentials.with_scopes( + ["fake_scope1"], default_scopes=["fake_scope2"] + ) + assert credentials._target_scopes == ["fake_scope1"] + + def test_id_token_success( + self, mock_donor_credentials, mock_authorizedsession_idtoken + ): + credentials = self.make_credentials(lifetime=None) + token = "token" + target_audience = "https://foo.bar" + + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=500) + ).isoformat("T") + "Z" + response_body = {"accessToken": token, "expireTime": expire_time} + + request = self.make_request( + data=json.dumps(response_body), status=http_client.OK + ) + + credentials.refresh(request) + + assert credentials.valid + assert not credentials.expired + + id_creds = impersonated_credentials.IDTokenCredentials( + credentials, target_audience=target_audience + ) + id_creds.refresh(request) + + assert id_creds.token == ID_TOKEN_DATA + assert id_creds.expiry == datetime.datetime.utcfromtimestamp(ID_TOKEN_EXPIRY) + + def test_id_token_metrics(self, mock_donor_credentials): + credentials = self.make_credentials(lifetime=None) + credentials.token = "token" + credentials.expiry = None + target_audience = "https://foo.bar" + + id_creds = impersonated_credentials.IDTokenCredentials( + credentials, target_audience=target_audience + ) + + with mock.patch( + "google.auth.metrics.token_request_id_token_impersonate", + return_value=ID_TOKEN_REQUEST_METRICS_HEADER_VALUE, + ): + with mock.patch( + "google.auth.transport.requests.AuthorizedSession.post", autospec=True + ) as mock_post: + data = {"token": ID_TOKEN_DATA} + mock_post.return_value = MockResponse(data, http_client.OK) + id_creds.refresh(None) + + assert id_creds.token == ID_TOKEN_DATA + assert id_creds.expiry == datetime.datetime.utcfromtimestamp( + ID_TOKEN_EXPIRY + ) + assert ( + mock_post.call_args.kwargs["headers"]["x-goog-api-client"] + == ID_TOKEN_REQUEST_METRICS_HEADER_VALUE + ) + + def test_id_token_from_credential( + self, mock_donor_credentials, mock_authorizedsession_idtoken + ): + credentials = self.make_credentials(lifetime=None) + target_credentials = self.make_credentials(lifetime=None) + expected_url = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/impersonated@project.iam.gserviceaccount.com:generateIdToken" + self._test_id_token_helper( + credentials, + target_credentials, + mock_donor_credentials, + mock_authorizedsession_idtoken, + expected_url, + ) + + def test_id_token_from_credential_nonGdu( + self, mock_donor_credentials, mock_authorizedsession_idtoken + ): + source_credentials = service_account.Credentials( + SIGNER, "some@email.com", TOKEN_URI, universe_domain="foo.bar" + ) + credentials = self.make_credentials( + lifetime=None, source_credentials=source_credentials + ) + target_credentials = self.make_credentials( + lifetime=None, source_credentials=source_credentials + ) + expected_url = "https://iamcredentials.foo.bar/v1/projects/-/serviceAccounts/impersonated@project.iam.gserviceaccount.com:generateIdToken" + self._test_id_token_helper( + credentials, + target_credentials, + mock_donor_credentials, + mock_authorizedsession_idtoken, + expected_url, + ) + + def _test_id_token_helper( + self, + credentials, + target_credentials, + mock_donor_credentials, + mock_authorizedsession_idtoken, + expected_url, + ): + token = "token" + target_audience = "https://foo.bar" + + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=500) + ).isoformat("T") + "Z" + response_body = {"accessToken": token, "expireTime": expire_time} + + request = self.make_request( + data=json.dumps(response_body), status=http_client.OK + ) + + credentials.refresh(request) + + assert credentials.valid + assert not credentials.expired + + id_creds = impersonated_credentials.IDTokenCredentials( + credentials, target_audience=target_audience, include_email=True + ) + id_creds = id_creds.from_credentials(target_credentials=target_credentials) + id_creds.refresh(request) + + args = mock_authorizedsession_idtoken.call_args.args + + assert args[2] == expected_url + + assert id_creds.token == ID_TOKEN_DATA + assert id_creds._include_email is True + assert id_creds._target_credentials is target_credentials + + def test_id_token_with_target_audience( + self, mock_donor_credentials, mock_authorizedsession_idtoken + ): + credentials = self.make_credentials(lifetime=None) + token = "token" + target_audience = "https://foo.bar" + + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=500) + ).isoformat("T") + "Z" + response_body = {"accessToken": token, "expireTime": expire_time} + + request = self.make_request( + data=json.dumps(response_body), status=http_client.OK + ) + + credentials.refresh(request) + + assert credentials.valid + assert not credentials.expired + + id_creds = impersonated_credentials.IDTokenCredentials( + credentials, include_email=True + ) + id_creds = id_creds.with_target_audience(target_audience=target_audience) + id_creds.refresh(request) + + assert id_creds.token == ID_TOKEN_DATA + assert id_creds.expiry == datetime.datetime.utcfromtimestamp(ID_TOKEN_EXPIRY) + assert id_creds._include_email is True + + def test_id_token_invalid_cred( + self, mock_donor_credentials, mock_authorizedsession_idtoken + ): + credentials = None + + with pytest.raises(exceptions.GoogleAuthError) as excinfo: + impersonated_credentials.IDTokenCredentials(credentials) + + assert excinfo.match("Provided Credential must be" " impersonated_credentials") + + def test_id_token_with_include_email( + self, mock_donor_credentials, mock_authorizedsession_idtoken + ): + credentials = self.make_credentials(lifetime=None) + token = "token" + target_audience = "https://foo.bar" + + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=500) + ).isoformat("T") + "Z" + response_body = {"accessToken": token, "expireTime": expire_time} + + request = self.make_request( + data=json.dumps(response_body), status=http_client.OK + ) + + credentials.refresh(request) + + assert credentials.valid + assert not credentials.expired + + id_creds = impersonated_credentials.IDTokenCredentials( + credentials, target_audience=target_audience + ) + id_creds = id_creds.with_include_email(True) + id_creds.refresh(request) + + assert id_creds.token == ID_TOKEN_DATA + + def test_id_token_with_quota_project( + self, mock_donor_credentials, mock_authorizedsession_idtoken + ): + credentials = self.make_credentials(lifetime=None) + token = "token" + target_audience = "https://foo.bar" + + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=500) + ).isoformat("T") + "Z" + response_body = {"accessToken": token, "expireTime": expire_time} + + request = self.make_request( + data=json.dumps(response_body), status=http_client.OK + ) + + credentials.refresh(request) + + assert credentials.valid + assert not credentials.expired + + id_creds = impersonated_credentials.IDTokenCredentials( + credentials, target_audience=target_audience + ) + id_creds = id_creds.with_quota_project("project-foo") + id_creds.refresh(request) + + assert id_creds.quota_project_id == "project-foo" + + def test_sign_jwt_request_success(self): + principal = "foo@example.com" + expected_signed_jwt = "correct_signed_jwt" + + response_body = {"keyId": "1", "signedJwt": expected_signed_jwt} + request = self.make_request( + data=json.dumps(response_body), status=http_client.OK + ) + + signed_jwt = impersonated_credentials._sign_jwt_request( + request=request, principal=principal, headers={}, payload={} + ) + + assert signed_jwt == expected_signed_jwt + request.assert_called_once_with( + url="https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/foo@example.com:signJwt", + method="POST", + headers={}, + body=json.dumps({"delegates": [], "payload": json.dumps({})}).encode( + "utf-8" + ), + ) + + def test_sign_jwt_request_http_error(self): + principal = "foo@example.com" + + request = self.make_request( + data="error_message", status=http_client.BAD_REQUEST + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + _ = impersonated_credentials._sign_jwt_request( + request=request, principal=principal, headers={}, payload={} + ) + + assert excinfo.match(impersonated_credentials._REFRESH_ERROR) + + assert excinfo.value.args[0] == "Unable to acquire impersonated credentials" + assert excinfo.value.args[1] == "error_message" + + def test_sign_jwt_request_invalid_response_error(self): + principal = "foo@example.com" + + request = self.make_request(data="invalid_data", status=http_client.OK) + + with pytest.raises(exceptions.RefreshError) as excinfo: + _ = impersonated_credentials._sign_jwt_request( + request=request, principal=principal, headers={}, payload={} + ) + + assert excinfo.match(impersonated_credentials._REFRESH_ERROR) + + assert ( + excinfo.value.args[0] + == "Unable to acquire impersonated credentials: No signed JWT in response." + ) + assert excinfo.value.args[1] == "invalid_data" diff --git a/packages/google-auth/tests/test_jwt.py b/packages/google-auth/tests/test_jwt.py new file mode 100644 index 000000000000..87cafab8b45e --- /dev/null +++ b/packages/google-auth/tests/test_jwt.py @@ -0,0 +1,702 @@ +# Copyright 2014 Google Inc. +# +# 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 base64 +import datetime +import json +import os + +import mock +import pytest # type: ignore + +from google.auth import _helpers +from google.auth import crypt +from google.auth import exceptions +from google.auth import jwt + + +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() + +with open(os.path.join(DATA_DIR, "public_cert.pem"), "rb") as fh: + PUBLIC_CERT_BYTES = fh.read() + +with open(os.path.join(DATA_DIR, "other_cert.pem"), "rb") as fh: + OTHER_CERT_BYTES = fh.read() + +with open(os.path.join(DATA_DIR, "es256_privatekey.pem"), "rb") as fh: + EC_PRIVATE_KEY_BYTES = fh.read() + +with open(os.path.join(DATA_DIR, "es256_public_cert.pem"), "rb") as fh: + EC_PUBLIC_CERT_BYTES = fh.read() + +with open(os.path.join(DATA_DIR, "es384_privatekey.pem"), "rb") as fh: + EC384_PRIVATE_KEY_BYTES = fh.read() + +with open(os.path.join(DATA_DIR, "es384_public_cert.pem"), "rb") as fh: + EC384_PUBLIC_CERT_BYTES = fh.read() + +SERVICE_ACCOUNT_JSON_FILE = os.path.join(DATA_DIR, "service_account.json") + +with open(SERVICE_ACCOUNT_JSON_FILE, "rb") as fh: + SERVICE_ACCOUNT_INFO = json.load(fh) + + +@pytest.fixture +def signer(): + return crypt.RSASigner.from_string(PRIVATE_KEY_BYTES, "1") + + +def test_encode_basic(signer): + test_payload = {"test": "value"} + encoded = jwt.encode(signer, test_payload) + header, payload, _, _ = jwt._unverified_decode(encoded) + assert payload == test_payload + assert header == {"typ": "JWT", "alg": "RS256", "kid": signer.key_id} + + +def test_encode_extra_headers(signer): + encoded = jwt.encode(signer, {}, header={"extra": "value"}) + header = jwt.decode_header(encoded) + assert header == { + "typ": "JWT", + "alg": "RS256", + "kid": signer.key_id, + "extra": "value", + } + + +def test_encode_custom_alg_in_headers(signer): + encoded = jwt.encode(signer, {}, header={"alg": "foo"}) + header = jwt.decode_header(encoded) + assert header == {"typ": "JWT", "alg": "foo", "kid": signer.key_id} + + +@pytest.fixture +def es256_signer(): + return crypt.ES256Signer.from_string(EC_PRIVATE_KEY_BYTES, "1") + + +@pytest.fixture +def es384_signer(): + return crypt.EsSigner.from_string(EC384_PRIVATE_KEY_BYTES, "1") + + +def test_encode_basic_es256(es256_signer): + test_payload = {"test": "value"} + encoded = jwt.encode(es256_signer, test_payload) + header, payload, _, _ = jwt._unverified_decode(encoded) + assert payload == test_payload + assert header == {"typ": "JWT", "alg": "ES256", "kid": es256_signer.key_id} + + +def test_encode_basic_es384(es384_signer): + test_payload = {"test": "value"} + encoded = jwt.encode(es384_signer, test_payload) + header, payload, _, _ = jwt._unverified_decode(encoded) + assert payload == test_payload + assert header == {"typ": "JWT", "alg": "ES384", "kid": es384_signer.key_id} + + +@pytest.fixture +def token_factory(signer, es256_signer, es384_signer): + def factory( + claims=None, key_id=None, use_es256_signer=False, use_es384_signer=False + ): + now = _helpers.datetime_to_secs(_helpers.utcnow()) + payload = { + "aud": "audience@example.com", + "iat": now, + "exp": now + 300, + "user": "billy bob", + "metadata": {"meta": "data"}, + } + payload.update(claims or {}) + + # False is specified to remove the signer's key id for testing + # headers without key ids. + if key_id is False: + signer._key_id = None + key_id = None + + if use_es256_signer: + return jwt.encode(es256_signer, payload, key_id=key_id) + elif use_es384_signer: + return jwt.encode(es384_signer, payload, key_id=key_id) + else: + return jwt.encode(signer, payload, key_id=key_id) + + return factory + + +def test_decode_valid(token_factory): + payload = jwt.decode(token_factory(), certs=PUBLIC_CERT_BYTES) + assert payload["aud"] == "audience@example.com" + assert payload["user"] == "billy bob" + assert payload["metadata"]["meta"] == "data" + + +def test_decode_header_object(token_factory): + payload = token_factory() + # Create a malformed JWT token with a number as a header instead of a + # dictionary (3 == base64d(M7==)) + payload = b"M7." + b".".join(payload.split(b".")[1:]) + + with pytest.raises(ValueError) as excinfo: + jwt.decode(payload, certs=PUBLIC_CERT_BYTES) + assert excinfo.match(r"Header segment should be a JSON object: " + str(b"M7")) + + +def test_decode_payload_object(signer): + # Create a malformed JWT token with a payload containing both "iat" and + # "exp" strings, although not as fields of a dictionary + payload = jwt.encode(signer, "iatexp") + + with pytest.raises(ValueError) as excinfo: + jwt.decode(payload, certs=PUBLIC_CERT_BYTES) + assert excinfo.match( + r"Payload segment should be a JSON object: " + str(b"ImlhdGV4cCI") + ) + + +def test_decode_valid_es256(token_factory): + payload = jwt.decode( + token_factory(use_es256_signer=True), certs=EC_PUBLIC_CERT_BYTES + ) + assert payload["aud"] == "audience@example.com" + assert payload["user"] == "billy bob" + assert payload["metadata"]["meta"] == "data" + + +def test_decode_valid_es384(token_factory): + payload = jwt.decode( + token_factory(use_es384_signer=True), certs=EC384_PUBLIC_CERT_BYTES + ) + assert payload["aud"] == "audience@example.com" + assert payload["user"] == "billy bob" + assert payload["metadata"]["meta"] == "data" + + +def test_decode_valid_with_audience(token_factory): + payload = jwt.decode( + token_factory(), certs=PUBLIC_CERT_BYTES, audience="audience@example.com" + ) + assert payload["aud"] == "audience@example.com" + assert payload["user"] == "billy bob" + assert payload["metadata"]["meta"] == "data" + + +def test_decode_valid_with_audience_list(token_factory): + payload = jwt.decode( + token_factory(), + certs=PUBLIC_CERT_BYTES, + audience=["audience@example.com", "another_audience@example.com"], + ) + assert payload["aud"] == "audience@example.com" + assert payload["user"] == "billy bob" + assert payload["metadata"]["meta"] == "data" + + +def test_decode_valid_unverified(token_factory): + payload = jwt.decode(token_factory(), certs=OTHER_CERT_BYTES, verify=False) + assert payload["aud"] == "audience@example.com" + assert payload["user"] == "billy bob" + assert payload["metadata"]["meta"] == "data" + + +def test_decode_bad_token_wrong_number_of_segments(): + with pytest.raises(ValueError) as excinfo: + jwt.decode("1.2", PUBLIC_CERT_BYTES) + assert excinfo.match(r"Wrong number of segments") + + +def test_decode_bad_token_not_base64(): + with pytest.raises((ValueError, TypeError)) as excinfo: + jwt.decode("1.2.3", PUBLIC_CERT_BYTES) + assert excinfo.match(r"Incorrect padding|more than a multiple of 4") + + +def test_decode_bad_token_not_json(): + token = b".".join([base64.urlsafe_b64encode(b"123!")] * 3) + with pytest.raises(ValueError) as excinfo: + jwt.decode(token, PUBLIC_CERT_BYTES) + assert excinfo.match(r"Can\'t parse segment") + + +def test_decode_bad_token_no_iat_or_exp(signer): + token = jwt.encode(signer, {"test": "value"}) + with pytest.raises(ValueError) as excinfo: + jwt.decode(token, PUBLIC_CERT_BYTES) + assert excinfo.match(r"Token does not contain required claim") + + +def test_decode_bad_token_too_early(token_factory): + token = token_factory( + claims={ + "iat": _helpers.datetime_to_secs( + _helpers.utcnow() + datetime.timedelta(hours=1) + ) + } + ) + with pytest.raises(ValueError) as excinfo: + jwt.decode(token, PUBLIC_CERT_BYTES, clock_skew_in_seconds=59) + assert excinfo.match(r"Token used too early") + + +def test_decode_bad_token_expired(token_factory): + token = token_factory( + claims={ + "exp": _helpers.datetime_to_secs( + _helpers.utcnow() - datetime.timedelta(hours=1) + ) + } + ) + with pytest.raises(ValueError) as excinfo: + jwt.decode(token, PUBLIC_CERT_BYTES, clock_skew_in_seconds=59) + assert excinfo.match(r"Token expired") + + +def test_decode_success_with_no_clock_skew(token_factory): + token = token_factory( + claims={ + "exp": _helpers.datetime_to_secs( + _helpers.utcnow() + datetime.timedelta(seconds=1) + ), + "iat": _helpers.datetime_to_secs( + _helpers.utcnow() - datetime.timedelta(seconds=1) + ), + } + ) + + jwt.decode(token, PUBLIC_CERT_BYTES) + + +def test_decode_success_with_custom_clock_skew(token_factory): + token = token_factory( + claims={ + "exp": _helpers.datetime_to_secs( + _helpers.utcnow() + datetime.timedelta(seconds=2) + ), + "iat": _helpers.datetime_to_secs( + _helpers.utcnow() - datetime.timedelta(seconds=2) + ), + } + ) + + jwt.decode(token, PUBLIC_CERT_BYTES, clock_skew_in_seconds=1) + + +def test_decode_bad_token_wrong_audience(token_factory): + token = token_factory() + audience = "audience2@example.com" + with pytest.raises(ValueError) as excinfo: + jwt.decode(token, PUBLIC_CERT_BYTES, audience=audience) + assert excinfo.match(r"Token has wrong audience") + + +def test_decode_bad_token_wrong_audience_list(token_factory): + token = token_factory() + audience = ["audience2@example.com", "audience3@example.com"] + with pytest.raises(ValueError) as excinfo: + jwt.decode(token, PUBLIC_CERT_BYTES, audience=audience) + assert excinfo.match(r"Token has wrong audience") + + +def test_decode_wrong_cert(token_factory): + with pytest.raises(ValueError) as excinfo: + jwt.decode(token_factory(), OTHER_CERT_BYTES) + assert excinfo.match(r"Could not verify token signature") + + +def test_decode_multicert_bad_cert(token_factory): + certs = {"1": OTHER_CERT_BYTES, "2": PUBLIC_CERT_BYTES} + with pytest.raises(ValueError) as excinfo: + jwt.decode(token_factory(), certs) + assert excinfo.match(r"Could not verify token signature") + + +def test_decode_no_cert(token_factory): + certs = {"2": PUBLIC_CERT_BYTES} + with pytest.raises(ValueError) as excinfo: + jwt.decode(token_factory(), certs) + assert excinfo.match(r"Certificate for key id 1 not found") + + +def test_decode_no_key_id(token_factory): + token = token_factory(key_id=False) + certs = {"2": PUBLIC_CERT_BYTES} + payload = jwt.decode(token, certs) + assert payload["user"] == "billy bob" + + +def test_decode_unknown_alg(): + headers = json.dumps({"kid": "1", "alg": "fakealg"}) + token = b".".join( + map(lambda seg: base64.b64encode(seg.encode("utf-8")), [headers, "{}", "sig"]) + ) + + with pytest.raises(ValueError) as excinfo: + jwt.decode(token) + assert excinfo.match(r"fakealg") + + +def test_decode_missing_crytography_alg(monkeypatch): + monkeypatch.delitem(jwt._ALGORITHM_TO_VERIFIER_CLASS, "ES256") + headers = json.dumps({"kid": "1", "alg": "ES256"}) + token = b".".join( + map(lambda seg: base64.b64encode(seg.encode("utf-8")), [headers, "{}", "sig"]) + ) + + with pytest.raises(ValueError) as excinfo: + jwt.decode(token) + assert excinfo.match(r"cryptography") + + +def test_roundtrip_explicit_key_id(token_factory): + token = token_factory(key_id="3") + certs = {"2": OTHER_CERT_BYTES, "3": PUBLIC_CERT_BYTES} + payload = jwt.decode(token, certs) + assert payload["user"] == "billy bob" + + +class TestCredentials(object): + SERVICE_ACCOUNT_EMAIL = "service-account@example.com" + SUBJECT = "subject" + AUDIENCE = "audience" + ADDITIONAL_CLAIMS = {"meta": "data"} + credentials = None + + @pytest.fixture(autouse=True) + def credentials_fixture(self, signer): + self.credentials = jwt.Credentials( + signer, + self.SERVICE_ACCOUNT_EMAIL, + self.SERVICE_ACCOUNT_EMAIL, + self.AUDIENCE, + ) + + def test_from_service_account_info(self): + with open(SERVICE_ACCOUNT_JSON_FILE, "r") as fh: + info = json.load(fh) + + credentials = jwt.Credentials.from_service_account_info( + info, audience=self.AUDIENCE + ) + + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._issuer == info["client_email"] + assert credentials._subject == info["client_email"] + assert credentials._audience == self.AUDIENCE + + def test_from_service_account_info_args(self): + info = SERVICE_ACCOUNT_INFO.copy() + + credentials = jwt.Credentials.from_service_account_info( + info, + subject=self.SUBJECT, + audience=self.AUDIENCE, + additional_claims=self.ADDITIONAL_CLAIMS, + ) + + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._issuer == info["client_email"] + assert credentials._subject == self.SUBJECT + assert credentials._audience == self.AUDIENCE + assert credentials._additional_claims == self.ADDITIONAL_CLAIMS + + def test_from_service_account_file(self): + info = SERVICE_ACCOUNT_INFO.copy() + + credentials = jwt.Credentials.from_service_account_file( + SERVICE_ACCOUNT_JSON_FILE, audience=self.AUDIENCE + ) + + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._issuer == info["client_email"] + assert credentials._subject == info["client_email"] + assert credentials._audience == self.AUDIENCE + + def test_from_service_account_file_args(self): + info = SERVICE_ACCOUNT_INFO.copy() + + credentials = jwt.Credentials.from_service_account_file( + SERVICE_ACCOUNT_JSON_FILE, + subject=self.SUBJECT, + audience=self.AUDIENCE, + additional_claims=self.ADDITIONAL_CLAIMS, + ) + + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._issuer == info["client_email"] + assert credentials._subject == self.SUBJECT + assert credentials._audience == self.AUDIENCE + assert credentials._additional_claims == self.ADDITIONAL_CLAIMS + + def test_from_signing_credentials(self): + jwt_from_signing = self.credentials.from_signing_credentials( + self.credentials, audience=mock.sentinel.new_audience + ) + jwt_from_info = jwt.Credentials.from_service_account_info( + SERVICE_ACCOUNT_INFO, audience=mock.sentinel.new_audience + ) + + assert isinstance(jwt_from_signing, jwt.Credentials) + assert jwt_from_signing._signer.key_id == jwt_from_info._signer.key_id + assert jwt_from_signing._issuer == jwt_from_info._issuer + assert jwt_from_signing._subject == jwt_from_info._subject + assert jwt_from_signing._audience == jwt_from_info._audience + + def test_default_state(self): + assert not self.credentials.valid + # Expiration hasn't been set yet + assert not self.credentials.expired + + def test_with_claims(self): + new_audience = "new_audience" + new_credentials = self.credentials.with_claims(audience=new_audience) + + assert new_credentials._signer == self.credentials._signer + assert new_credentials._issuer == self.credentials._issuer + assert new_credentials._subject == self.credentials._subject + assert new_credentials._audience == new_audience + assert new_credentials._additional_claims == self.credentials._additional_claims + assert new_credentials._quota_project_id == self.credentials._quota_project_id + + def test__make_jwt_without_audience(self): + cred = jwt.Credentials.from_service_account_info( + SERVICE_ACCOUNT_INFO.copy(), + subject=self.SUBJECT, + audience=None, + additional_claims={"scope": "foo bar"}, + ) + token, _ = cred._make_jwt() + payload = jwt.decode(token, PUBLIC_CERT_BYTES) + assert payload["scope"] == "foo bar" + assert "aud" not in payload + + def test_with_quota_project(self): + quota_project_id = "project-foo" + + new_credentials = self.credentials.with_quota_project(quota_project_id) + assert new_credentials._signer == self.credentials._signer + assert new_credentials._issuer == self.credentials._issuer + assert new_credentials._subject == self.credentials._subject + assert new_credentials._audience == self.credentials._audience + assert new_credentials._additional_claims == self.credentials._additional_claims + assert new_credentials.additional_claims == self.credentials._additional_claims + assert new_credentials._quota_project_id == quota_project_id + + def test_sign_bytes(self): + to_sign = b"123" + signature = self.credentials.sign_bytes(to_sign) + assert crypt.verify_signature(to_sign, signature, PUBLIC_CERT_BYTES) + + def test_signer(self): + assert isinstance(self.credentials.signer, crypt.RSASigner) + + def test_signer_email(self): + assert self.credentials.signer_email == SERVICE_ACCOUNT_INFO["client_email"] + + def _verify_token(self, token): + payload = jwt.decode(token, PUBLIC_CERT_BYTES) + assert payload["iss"] == self.SERVICE_ACCOUNT_EMAIL + return payload + + def test_refresh(self): + self.credentials.refresh(None) + assert self.credentials.valid + assert not self.credentials.expired + + def test_expired(self): + assert not self.credentials.expired + + self.credentials.refresh(None) + assert not self.credentials.expired + + with mock.patch("google.auth._helpers.utcnow") as now: + one_day = datetime.timedelta(days=1) + now.return_value = self.credentials.expiry + one_day + assert self.credentials.expired + + def test_before_request(self): + headers = {} + + self.credentials.refresh(None) + self.credentials.before_request( + None, "GET", "http://example.com?a=1#3", headers + ) + + header_value = headers["authorization"] + _, token = header_value.split(" ") + + # Since the audience is set, it should use the existing token. + assert token.encode("utf-8") == self.credentials.token + + payload = self._verify_token(token) + assert payload["aud"] == self.AUDIENCE + + def test_before_request_refreshes(self): + assert not self.credentials.valid + self.credentials.before_request(None, "GET", "http://example.com?a=1#3", {}) + assert self.credentials.valid + + +class TestOnDemandCredentials(object): + SERVICE_ACCOUNT_EMAIL = "service-account@example.com" + SUBJECT = "subject" + ADDITIONAL_CLAIMS = {"meta": "data"} + credentials = None + + @pytest.fixture(autouse=True) + def credentials_fixture(self, signer): + self.credentials = jwt.OnDemandCredentials( + signer, + self.SERVICE_ACCOUNT_EMAIL, + self.SERVICE_ACCOUNT_EMAIL, + max_cache_size=2, + ) + + def test_from_service_account_info(self): + with open(SERVICE_ACCOUNT_JSON_FILE, "r") as fh: + info = json.load(fh) + + credentials = jwt.OnDemandCredentials.from_service_account_info(info) + + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._issuer == info["client_email"] + assert credentials._subject == info["client_email"] + + def test_from_service_account_info_args(self): + info = SERVICE_ACCOUNT_INFO.copy() + + credentials = jwt.OnDemandCredentials.from_service_account_info( + info, subject=self.SUBJECT, additional_claims=self.ADDITIONAL_CLAIMS + ) + + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._issuer == info["client_email"] + assert credentials._subject == self.SUBJECT + assert credentials._additional_claims == self.ADDITIONAL_CLAIMS + + def test_from_service_account_file(self): + info = SERVICE_ACCOUNT_INFO.copy() + + credentials = jwt.OnDemandCredentials.from_service_account_file( + SERVICE_ACCOUNT_JSON_FILE + ) + + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._issuer == info["client_email"] + assert credentials._subject == info["client_email"] + + def test_from_service_account_file_args(self): + info = SERVICE_ACCOUNT_INFO.copy() + + credentials = jwt.OnDemandCredentials.from_service_account_file( + SERVICE_ACCOUNT_JSON_FILE, + subject=self.SUBJECT, + additional_claims=self.ADDITIONAL_CLAIMS, + ) + + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._issuer == info["client_email"] + assert credentials._subject == self.SUBJECT + assert credentials._additional_claims == self.ADDITIONAL_CLAIMS + + def test_from_signing_credentials(self): + jwt_from_signing = self.credentials.from_signing_credentials(self.credentials) + jwt_from_info = jwt.OnDemandCredentials.from_service_account_info( + SERVICE_ACCOUNT_INFO + ) + + assert isinstance(jwt_from_signing, jwt.OnDemandCredentials) + assert jwt_from_signing._signer.key_id == jwt_from_info._signer.key_id + assert jwt_from_signing._issuer == jwt_from_info._issuer + assert jwt_from_signing._subject == jwt_from_info._subject + + def test_default_state(self): + # Credentials are *always* valid. + assert self.credentials.valid + # Credentials *never* expire. + assert not self.credentials.expired + + def test_with_claims(self): + new_claims = {"meep": "moop"} + new_credentials = self.credentials.with_claims(additional_claims=new_claims) + + assert new_credentials._signer == self.credentials._signer + assert new_credentials._issuer == self.credentials._issuer + assert new_credentials._subject == self.credentials._subject + assert new_credentials._additional_claims == new_claims + + def test_with_quota_project(self): + quota_project_id = "project-foo" + new_credentials = self.credentials.with_quota_project(quota_project_id) + + assert new_credentials._signer == self.credentials._signer + assert new_credentials._issuer == self.credentials._issuer + assert new_credentials._subject == self.credentials._subject + assert new_credentials._additional_claims == self.credentials._additional_claims + assert new_credentials._quota_project_id == quota_project_id + + def test_sign_bytes(self): + to_sign = b"123" + signature = self.credentials.sign_bytes(to_sign) + assert crypt.verify_signature(to_sign, signature, PUBLIC_CERT_BYTES) + + def test_signer(self): + assert isinstance(self.credentials.signer, crypt.RSASigner) + + def test_signer_email(self): + assert self.credentials.signer_email == SERVICE_ACCOUNT_INFO["client_email"] + + def _verify_token(self, token): + payload = jwt.decode(token, PUBLIC_CERT_BYTES) + assert payload["iss"] == self.SERVICE_ACCOUNT_EMAIL + return payload + + def test_refresh(self): + with pytest.raises(exceptions.RefreshError): + self.credentials.refresh(None) + + def test_before_request(self): + headers = {} + + self.credentials.before_request( + None, "GET", "http://example.com?a=1#3", headers + ) + + _, token = headers["authorization"].split(" ") + payload = self._verify_token(token) + + assert payload["aud"] == "http://example.com" + + # Making another request should re-use the same token. + self.credentials.before_request(None, "GET", "http://example.com?b=2", headers) + + _, new_token = headers["authorization"].split(" ") + + assert new_token == token + + def test_expired_token(self): + self.credentials._cache["audience"] = ( + mock.sentinel.token, + datetime.datetime.min, + ) + + token = self.credentials._get_jwt_for_audience("audience") + + assert token != mock.sentinel.token diff --git a/packages/google-auth/tests/test_metrics.py b/packages/google-auth/tests/test_metrics.py new file mode 100644 index 000000000000..ba9389267401 --- /dev/null +++ b/packages/google-auth/tests/test_metrics.py @@ -0,0 +1,96 @@ +# Copyright 2014 Google Inc. +# +# 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 platform + +import mock + +from google.auth import metrics +from google.auth import version + + +def test_add_metric_header(): + headers = {} + metrics.add_metric_header(headers, None) + assert headers == {} + + headers = {"x-goog-api-client": "foo"} + metrics.add_metric_header(headers, "bar") + assert headers == {"x-goog-api-client": "foo bar"} + + headers = {} + metrics.add_metric_header(headers, "bar") + assert headers == {"x-goog-api-client": "bar"} + + +@mock.patch.object(platform, "python_version", return_value="3.7") +def test_versions(mock_python_version): + version_save = version.__version__ + version.__version__ = "1.1" + assert metrics.python_and_auth_lib_version() == "gl-python/3.7 auth/1.1" + version.__version__ = version_save + + +@mock.patch( + "google.auth.metrics.python_and_auth_lib_version", + return_value="gl-python/3.7 auth/1.1", +) +def test_metric_values(mock_python_and_auth_lib_version): + assert ( + metrics.token_request_access_token_mds() + == "gl-python/3.7 auth/1.1 auth-request-type/at cred-type/mds" + ) + assert ( + metrics.token_request_id_token_mds() + == "gl-python/3.7 auth/1.1 auth-request-type/it cred-type/mds" + ) + assert ( + metrics.token_request_access_token_impersonate() + == "gl-python/3.7 auth/1.1 auth-request-type/at cred-type/imp" + ) + assert ( + metrics.token_request_id_token_impersonate() + == "gl-python/3.7 auth/1.1 auth-request-type/it cred-type/imp" + ) + assert ( + metrics.token_request_access_token_sa_assertion() + == "gl-python/3.7 auth/1.1 auth-request-type/at cred-type/sa" + ) + assert ( + metrics.token_request_id_token_sa_assertion() + == "gl-python/3.7 auth/1.1 auth-request-type/it cred-type/sa" + ) + assert metrics.token_request_user() == "gl-python/3.7 auth/1.1 cred-type/u" + assert metrics.mds_ping() == "gl-python/3.7 auth/1.1 auth-request-type/mds" + assert metrics.reauth_start() == "gl-python/3.7 auth/1.1 auth-request-type/re-start" + assert ( + metrics.reauth_continue() == "gl-python/3.7 auth/1.1 auth-request-type/re-cont" + ) + + +@mock.patch( + "google.auth.metrics.python_and_auth_lib_version", + return_value="gl-python/3.7 auth/1.1", +) +def test_byoid_metric_header(mock_python_and_auth_lib_version): + metrics_options = {} + assert ( + metrics.byoid_metrics_header(metrics_options) + == "gl-python/3.7 auth/1.1 google-byoid-sdk" + ) + metrics_options["testKey"] = "testValue" + assert ( + metrics.byoid_metrics_header(metrics_options) + == "gl-python/3.7 auth/1.1 google-byoid-sdk testKey/testValue" + ) diff --git a/packages/google-auth/tests/test_packaging.py b/packages/google-auth/tests/test_packaging.py new file mode 100644 index 000000000000..e87b3a21b9be --- /dev/null +++ b/packages/google-auth/tests/test_packaging.py @@ -0,0 +1,30 @@ +# 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. + +import os +import subprocess +import sys + + +def test_namespace_package_compat(tmp_path): + """ + The ``google`` namespace package should not be masked + by the presence of ``google-auth``. + """ + google = tmp_path / "google" + google.mkdir() + google.joinpath("othermod.py").write_text("") + env = dict(os.environ, PYTHONPATH=str(tmp_path)) + cmd = [sys.executable, "-m", "google.othermod"] + subprocess.check_call(cmd, env=env) diff --git a/packages/google-auth/tests/test_pluggable.py b/packages/google-auth/tests/test_pluggable.py new file mode 100644 index 000000000000..5c1b4e6600bc --- /dev/null +++ b/packages/google-auth/tests/test_pluggable.py @@ -0,0 +1,1285 @@ +# 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. + +import json +import os +import subprocess + +import mock +import pytest # type: ignore + +from google.auth import exceptions +from google.auth import pluggable +from google.auth.credentials import DEFAULT_UNIVERSE_DOMAIN +from tests.test__default import WORKFORCE_AUDIENCE + +CLIENT_ID = "username" +CLIENT_SECRET = "password" +# Base64 encoding of "username:password". +BASIC_AUTH_ENCODING = "dXNlcm5hbWU6cGFzc3dvcmQ=" +SERVICE_ACCOUNT_EMAIL = "service-1234@service-name.iam.gserviceaccount.com" +SERVICE_ACCOUNT_IMPERSONATION_URL_BASE = ( + "https://us-east1-iamcredentials.googleapis.com" +) +SERVICE_ACCOUNT_IMPERSONATION_URL_ROUTE = ( + "/v1/projects/-/serviceAccounts/{}:generateAccessToken".format( + SERVICE_ACCOUNT_EMAIL + ) +) +SERVICE_ACCOUNT_IMPERSONATION_URL = ( + SERVICE_ACCOUNT_IMPERSONATION_URL_BASE + SERVICE_ACCOUNT_IMPERSONATION_URL_ROUTE +) +QUOTA_PROJECT_ID = "QUOTA_PROJECT_ID" +SCOPES = ["scope1", "scope2"] +SUBJECT_TOKEN_FIELD_NAME = "access_token" + +TOKEN_URL = "https://sts.googleapis.com/v1/token" +TOKEN_INFO_URL = "https://sts.googleapis.com/v1/introspect" +SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt" +AUDIENCE = "//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID" + +VALID_TOKEN_URLS = [ + "https://sts.googleapis.com", + "https://us-east-1.sts.googleapis.com", + "https://US-EAST-1.sts.googleapis.com", + "https://sts.us-east-1.googleapis.com", + "https://sts.US-WEST-1.googleapis.com", + "https://us-east-1-sts.googleapis.com", + "https://US-WEST-1-sts.googleapis.com", + "https://us-west-1-sts.googleapis.com/path?query", + "https://sts-us-east-1.p.googleapis.com", +] +INVALID_TOKEN_URLS = [ + "https://iamcredentials.googleapis.com", + "sts.googleapis.com", + "https://", + "http://sts.googleapis.com", + "https://st.s.googleapis.com", + "https://us-eas\t-1.sts.googleapis.com", + "https:/us-east-1.sts.googleapis.com", + "https://US-WE/ST-1-sts.googleapis.com", + "https://sts-us-east-1.googleapis.com", + "https://sts-US-WEST-1.googleapis.com", + "testhttps://us-east-1.sts.googleapis.com", + "https://us-east-1.sts.googleapis.comevil.com", + "https://us-east-1.us-east-1.sts.googleapis.com", + "https://us-ea.s.t.sts.googleapis.com", + "https://sts.googleapis.comevil.com", + "hhttps://us-east-1.sts.googleapis.com", + "https://us- -1.sts.googleapis.com", + "https://-sts.googleapis.com", + "https://us-east-1.sts.googleapis.com.evil.com", + "https://sts.pgoogleapis.com", + "https://p.googleapis.com", + "https://sts.p.com", + "http://sts.p.googleapis.com", + "https://xyz-sts.p.googleapis.com", + "https://sts-xyz.123.p.googleapis.com", + "https://sts-xyz.p1.googleapis.com", + "https://sts-xyz.p.foo.com", + "https://sts-xyz.p.foo.googleapis.com", +] +VALID_SERVICE_ACCOUNT_IMPERSONATION_URLS = [ + "https://iamcredentials.googleapis.com", + "https://us-east-1.iamcredentials.googleapis.com", + "https://US-EAST-1.iamcredentials.googleapis.com", + "https://iamcredentials.us-east-1.googleapis.com", + "https://iamcredentials.US-WEST-1.googleapis.com", + "https://us-east-1-iamcredentials.googleapis.com", + "https://US-WEST-1-iamcredentials.googleapis.com", + "https://us-west-1-iamcredentials.googleapis.com/path?query", + "https://iamcredentials-us-east-1.p.googleapis.com", +] +INVALID_SERVICE_ACCOUNT_IMPERSONATION_URLS = [ + "https://sts.googleapis.com", + "iamcredentials.googleapis.com", + "https://", + "http://iamcredentials.googleapis.com", + "https://iamcre.dentials.googleapis.com", + "https://us-eas\t-1.iamcredentials.googleapis.com", + "https:/us-east-1.iamcredentials.googleapis.com", + "https://US-WE/ST-1-iamcredentials.googleapis.com", + "https://iamcredentials-us-east-1.googleapis.com", + "https://iamcredentials-US-WEST-1.googleapis.com", + "testhttps://us-east-1.iamcredentials.googleapis.com", + "https://us-east-1.iamcredentials.googleapis.comevil.com", + "https://us-east-1.us-east-1.iamcredentials.googleapis.com", + "https://us-ea.s.t.iamcredentials.googleapis.com", + "https://iamcredentials.googleapis.comevil.com", + "hhttps://us-east-1.iamcredentials.googleapis.com", + "https://us- -1.iamcredentials.googleapis.com", + "https://-iamcredentials.googleapis.com", + "https://us-east-1.iamcredentials.googleapis.com.evil.com", + "https://iamcredentials.pgoogleapis.com", + "https://p.googleapis.com", + "https://iamcredentials.p.com", + "http://iamcredentials.p.googleapis.com", + "https://xyz-iamcredentials.p.googleapis.com", + "https://iamcredentials-xyz.123.p.googleapis.com", + "https://iamcredentials-xyz.p1.googleapis.com", + "https://iamcredentials-xyz.p.foo.com", + "https://iamcredentials-xyz.p.foo.googleapis.com", +] + + +class TestCredentials(object): + CREDENTIAL_SOURCE_EXECUTABLE_COMMAND = ( + "/fake/external/excutable --arg1=value1 --arg2=value2" + ) + CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE = "fake_output_file" + CREDENTIAL_SOURCE_EXECUTABLE = { + "command": CREDENTIAL_SOURCE_EXECUTABLE_COMMAND, + "timeout_millis": 30000, + "interactive_timeout_millis": 300000, + "output_file": CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, + } + CREDENTIAL_SOURCE = {"executable": CREDENTIAL_SOURCE_EXECUTABLE} + EXECUTABLE_OIDC_TOKEN = "FAKE_ID_TOKEN" + EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN = { + "version": 1, + "success": True, + "token_type": "urn:ietf:params:oauth:token-type:id_token", + "id_token": EXECUTABLE_OIDC_TOKEN, + "expiration_time": 9999999999, + } + EXECUTABLE_SUCCESSFUL_OIDC_NO_EXPIRATION_TIME_RESPONSE_ID_TOKEN = { + "version": 1, + "success": True, + "token_type": "urn:ietf:params:oauth:token-type:id_token", + "id_token": EXECUTABLE_OIDC_TOKEN, + } + EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_JWT = { + "version": 1, + "success": True, + "token_type": "urn:ietf:params:oauth:token-type:jwt", + "id_token": EXECUTABLE_OIDC_TOKEN, + "expiration_time": 9999999999, + } + EXECUTABLE_SUCCESSFUL_OIDC_NO_EXPIRATION_TIME_RESPONSE_JWT = { + "version": 1, + "success": True, + "token_type": "urn:ietf:params:oauth:token-type:jwt", + "id_token": EXECUTABLE_OIDC_TOKEN, + } + EXECUTABLE_SAML_TOKEN = "FAKE_SAML_RESPONSE" + EXECUTABLE_SUCCESSFUL_SAML_RESPONSE = { + "version": 1, + "success": True, + "token_type": "urn:ietf:params:oauth:token-type:saml2", + "saml_response": EXECUTABLE_SAML_TOKEN, + "expiration_time": 9999999999, + } + EXECUTABLE_SUCCESSFUL_SAML_NO_EXPIRATION_TIME_RESPONSE = { + "version": 1, + "success": True, + "token_type": "urn:ietf:params:oauth:token-type:saml2", + "saml_response": EXECUTABLE_SAML_TOKEN, + } + EXECUTABLE_FAILED_RESPONSE = { + "version": 1, + "success": False, + "code": "401", + "message": "Permission denied. Caller not authorized", + } + CREDENTIAL_URL = "http://fakeurl.com" + + @classmethod + def make_pluggable( + cls, + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + token_info_url=TOKEN_INFO_URL, + client_id=None, + client_secret=None, + quota_project_id=None, + scopes=None, + default_scopes=None, + service_account_impersonation_url=None, + credential_source=None, + workforce_pool_user_project=None, + interactive=None, + ): + return pluggable.Credentials( + audience=audience, + subject_token_type=subject_token_type, + token_url=token_url, + token_info_url=token_info_url, + service_account_impersonation_url=service_account_impersonation_url, + credential_source=credential_source, + client_id=client_id, + client_secret=client_secret, + quota_project_id=quota_project_id, + scopes=scopes, + default_scopes=default_scopes, + workforce_pool_user_project=workforce_pool_user_project, + interactive=interactive, + ) + + def test_from_constructor_and_injection(self): + credentials = pluggable.Credentials( + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + token_info_url=TOKEN_INFO_URL, + credential_source=self.CREDENTIAL_SOURCE, + interactive=True, + ) + setattr(credentials, "_tokeninfo_username", "mock_external_account_id") + + assert isinstance(credentials, pluggable.Credentials) + assert credentials.interactive + assert credentials.external_account_id == "mock_external_account_id" + + @mock.patch.object(pluggable.Credentials, "__init__", return_value=None) + def test_from_info_full_options(self, mock_init): + credentials = pluggable.Credentials.from_info( + { + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "token_info_url": TOKEN_INFO_URL, + "service_account_impersonation_url": SERVICE_ACCOUNT_IMPERSONATION_URL, + "service_account_impersonation": {"token_lifetime_seconds": 2800}, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "quota_project_id": QUOTA_PROJECT_ID, + "credential_source": self.CREDENTIAL_SOURCE, + } + ) + + # Confirm pluggable.Credentials instantiated with expected attributes. + assert isinstance(credentials, pluggable.Credentials) + mock_init.assert_called_once_with( + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + token_info_url=TOKEN_INFO_URL, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + service_account_impersonation_options={"token_lifetime_seconds": 2800}, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + credential_source=self.CREDENTIAL_SOURCE, + quota_project_id=QUOTA_PROJECT_ID, + workforce_pool_user_project=None, + universe_domain=DEFAULT_UNIVERSE_DOMAIN, + trust_boundary=None, + ) + + @mock.patch.object(pluggable.Credentials, "__init__", return_value=None) + def test_from_info_required_options_only(self, mock_init): + credentials = pluggable.Credentials.from_info( + { + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "credential_source": self.CREDENTIAL_SOURCE, + } + ) + + # Confirm pluggable.Credentials instantiated with expected attributes. + assert isinstance(credentials, pluggable.Credentials) + mock_init.assert_called_once_with( + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + token_info_url=None, + service_account_impersonation_url=None, + service_account_impersonation_options={}, + client_id=None, + client_secret=None, + credential_source=self.CREDENTIAL_SOURCE, + quota_project_id=None, + workforce_pool_user_project=None, + universe_domain=DEFAULT_UNIVERSE_DOMAIN, + trust_boundary=None, + ) + + @mock.patch.object(pluggable.Credentials, "__init__", return_value=None) + def test_from_file_full_options(self, mock_init, tmpdir): + info = { + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "token_info_url": TOKEN_INFO_URL, + "service_account_impersonation_url": SERVICE_ACCOUNT_IMPERSONATION_URL, + "service_account_impersonation": {"token_lifetime_seconds": 2800}, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "quota_project_id": QUOTA_PROJECT_ID, + "credential_source": self.CREDENTIAL_SOURCE, + } + config_file = tmpdir.join("config.json") + config_file.write(json.dumps(info)) + credentials = pluggable.Credentials.from_file(str(config_file)) + + # Confirm pluggable.Credentials instantiated with expected attributes. + assert isinstance(credentials, pluggable.Credentials) + mock_init.assert_called_once_with( + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + token_info_url=TOKEN_INFO_URL, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + service_account_impersonation_options={"token_lifetime_seconds": 2800}, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + credential_source=self.CREDENTIAL_SOURCE, + quota_project_id=QUOTA_PROJECT_ID, + workforce_pool_user_project=None, + universe_domain=DEFAULT_UNIVERSE_DOMAIN, + trust_boundary=None, + ) + + @mock.patch.object(pluggable.Credentials, "__init__", return_value=None) + def test_from_file_required_options_only(self, mock_init, tmpdir): + info = { + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "credential_source": self.CREDENTIAL_SOURCE, + } + config_file = tmpdir.join("config.json") + config_file.write(json.dumps(info)) + credentials = pluggable.Credentials.from_file(str(config_file)) + + # Confirm pluggable.Credentials instantiated with expected attributes. + assert isinstance(credentials, pluggable.Credentials) + mock_init.assert_called_once_with( + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + token_info_url=None, + service_account_impersonation_url=None, + service_account_impersonation_options={}, + client_id=None, + client_secret=None, + credential_source=self.CREDENTIAL_SOURCE, + quota_project_id=None, + workforce_pool_user_project=None, + universe_domain=DEFAULT_UNIVERSE_DOMAIN, + trust_boundary=None, + ) + + def test_constructor_invalid_options(self): + credential_source = {"unsupported": "value"} + + with pytest.raises(ValueError) as excinfo: + self.make_pluggable(credential_source=credential_source) + + assert excinfo.match(r"Missing credential_source") + + def test_constructor_invalid_credential_source(self): + with pytest.raises(ValueError) as excinfo: + self.make_pluggable(credential_source="non-dict") + + assert excinfo.match(r"Missing credential_source") + + def test_info_with_credential_source(self): + credentials = self.make_pluggable( + credential_source=self.CREDENTIAL_SOURCE.copy() + ) + + assert credentials.info == { + "type": "external_account", + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "token_info_url": TOKEN_INFO_URL, + "credential_source": self.CREDENTIAL_SOURCE, + "universe_domain": DEFAULT_UNIVERSE_DOMAIN, + } + + def test_token_info_url(self): + credentials = self.make_pluggable( + credential_source=self.CREDENTIAL_SOURCE.copy() + ) + + assert credentials.token_info_url == TOKEN_INFO_URL + + def test_token_info_url_custom(self): + for url in VALID_TOKEN_URLS: + credentials = self.make_pluggable( + credential_source=self.CREDENTIAL_SOURCE.copy(), + token_info_url=(url + "/introspect"), + ) + + assert credentials.token_info_url == url + "/introspect" + + def test_token_info_url_negative(self): + credentials = self.make_pluggable( + credential_source=self.CREDENTIAL_SOURCE.copy(), token_info_url=None + ) + + assert not credentials.token_info_url + + def test_token_url_custom(self): + for url in VALID_TOKEN_URLS: + credentials = self.make_pluggable( + credential_source=self.CREDENTIAL_SOURCE.copy(), + token_url=(url + "/token"), + ) + + assert credentials._token_url == (url + "/token") + + def test_service_account_impersonation_url_custom(self): + for url in VALID_SERVICE_ACCOUNT_IMPERSONATION_URLS: + credentials = self.make_pluggable( + credential_source=self.CREDENTIAL_SOURCE.copy(), + service_account_impersonation_url=( + url + SERVICE_ACCOUNT_IMPERSONATION_URL_ROUTE + ), + ) + + assert credentials._service_account_impersonation_url == ( + url + SERVICE_ACCOUNT_IMPERSONATION_URL_ROUTE + ) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_successfully(self, tmpdir): + ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE = tmpdir.join( + "actual_output_file" + ) + ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE = { + "command": "command", + "interactive_timeout_millis": 300000, + "output_file": ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, + } + ACTUAL_CREDENTIAL_SOURCE = {"executable": ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE} + + testData = { + "subject_token_oidc_id_token": { + "stdout": json.dumps( + self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN + ).encode("UTF-8"), + "impersonation_url": SERVICE_ACCOUNT_IMPERSONATION_URL, + "file_content": self.EXECUTABLE_SUCCESSFUL_OIDC_NO_EXPIRATION_TIME_RESPONSE_ID_TOKEN, + "expect_token": self.EXECUTABLE_OIDC_TOKEN, + }, + "subject_token_oidc_id_token_interacitve_mode": { + "audience": WORKFORCE_AUDIENCE, + "file_content": self.EXECUTABLE_SUCCESSFUL_OIDC_NO_EXPIRATION_TIME_RESPONSE_ID_TOKEN, + "interactive": True, + "expect_token": self.EXECUTABLE_OIDC_TOKEN, + }, + "subject_token_oidc_jwt": { + "stdout": json.dumps( + self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_JWT + ).encode("UTF-8"), + "impersonation_url": SERVICE_ACCOUNT_IMPERSONATION_URL, + "file_content": self.EXECUTABLE_SUCCESSFUL_OIDC_NO_EXPIRATION_TIME_RESPONSE_JWT, + "expect_token": self.EXECUTABLE_OIDC_TOKEN, + }, + "subject_token_oidc_jwt_interactive_mode": { + "audience": WORKFORCE_AUDIENCE, + "file_content": self.EXECUTABLE_SUCCESSFUL_OIDC_NO_EXPIRATION_TIME_RESPONSE_JWT, + "interactive": True, + "expect_token": self.EXECUTABLE_OIDC_TOKEN, + }, + "subject_token_saml": { + "stdout": json.dumps(self.EXECUTABLE_SUCCESSFUL_SAML_RESPONSE).encode( + "UTF-8" + ), + "impersonation_url": SERVICE_ACCOUNT_IMPERSONATION_URL, + "file_content": self.EXECUTABLE_SUCCESSFUL_SAML_NO_EXPIRATION_TIME_RESPONSE, + "expect_token": self.EXECUTABLE_SAML_TOKEN, + }, + "subject_token_saml_interactive_mode": { + "audience": WORKFORCE_AUDIENCE, + "file_content": self.EXECUTABLE_SUCCESSFUL_SAML_NO_EXPIRATION_TIME_RESPONSE, + "interactive": True, + "expect_token": self.EXECUTABLE_SAML_TOKEN, + }, + } + + for data in testData.values(): + with open( + ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, "w" + ) as output_file: + json.dump(data.get("file_content"), output_file) + + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess( + args=[], stdout=data.get("stdout"), returncode=0 + ), + ): + credentials = self.make_pluggable( + audience=data.get("audience", AUDIENCE), + service_account_impersonation_url=data.get("impersonation_url"), + credential_source=ACTUAL_CREDENTIAL_SOURCE, + interactive=data.get("interactive", False), + ) + subject_token = credentials.retrieve_subject_token(None) + assert subject_token == data.get("expect_token") + os.remove(ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_saml(self): + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess( + args=[], + stdout=json.dumps(self.EXECUTABLE_SUCCESSFUL_SAML_RESPONSE).encode( + "UTF-8" + ), + returncode=0, + ), + ): + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + + subject_token = credentials.retrieve_subject_token(None) + + assert subject_token == self.EXECUTABLE_SAML_TOKEN + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_saml_interactive_mode(self, tmpdir): + ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE = tmpdir.join( + "actual_output_file" + ) + ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE = { + "command": "command", + "interactive_timeout_millis": 300000, + "output_file": ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, + } + ACTUAL_CREDENTIAL_SOURCE = {"executable": ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE} + with open(ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, "w") as output_file: + json.dump( + self.EXECUTABLE_SUCCESSFUL_SAML_NO_EXPIRATION_TIME_RESPONSE, output_file + ) + + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess(args=[], returncode=0), + ): + credentials = self.make_pluggable( + audience=WORKFORCE_AUDIENCE, + credential_source=ACTUAL_CREDENTIAL_SOURCE, + interactive=True, + ) + + subject_token = credentials.retrieve_subject_token(None) + + assert subject_token == self.EXECUTABLE_SAML_TOKEN + os.remove(ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_failed(self): + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess( + args=[], + stdout=json.dumps(self.EXECUTABLE_FAILED_RESPONSE).encode("UTF-8"), + returncode=0, + ), + ): + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + + with pytest.raises(exceptions.RefreshError) as excinfo: + _ = credentials.retrieve_subject_token(None) + + assert excinfo.match( + r"Executable returned unsuccessful response: code: 401, message: Permission denied. Caller not authorized." + ) + + @mock.patch.dict( + os.environ, + { + "GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1", + "GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE": "1", + }, + ) + def test_retrieve_subject_token_failed_interactive_mode(self, tmpdir): + ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE = tmpdir.join( + "actual_output_file" + ) + ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE = { + "command": "command", + "interactive_timeout_millis": 300000, + "output_file": ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, + } + ACTUAL_CREDENTIAL_SOURCE = {"executable": ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE} + with open( + ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, "w", encoding="utf-8" + ) as output_file: + json.dump(self.EXECUTABLE_FAILED_RESPONSE, output_file) + + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess(args=[], returncode=0), + ): + credentials = self.make_pluggable( + audience=WORKFORCE_AUDIENCE, + credential_source=ACTUAL_CREDENTIAL_SOURCE, + interactive=True, + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + _ = credentials.retrieve_subject_token(None) + + assert excinfo.match( + r"Executable returned unsuccessful response: code: 401, message: Permission denied. Caller not authorized." + ) + os.remove(ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "0"}) + def test_retrieve_subject_token_not_allowd(self): + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess( + args=[], + stdout=json.dumps( + self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN + ).encode("UTF-8"), + returncode=0, + ), + ): + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + + with pytest.raises(ValueError) as excinfo: + _ = credentials.retrieve_subject_token(None) + + assert excinfo.match(r"Executables need to be explicitly allowed") + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_invalid_version(self): + EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_VERSION_2 = { + "version": 2, + "success": True, + "token_type": "urn:ietf:params:oauth:token-type:id_token", + "id_token": self.EXECUTABLE_OIDC_TOKEN, + "expiration_time": 9999999999, + } + + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess( + args=[], + stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_VERSION_2).encode( + "UTF-8" + ), + returncode=0, + ), + ): + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + + with pytest.raises(exceptions.RefreshError) as excinfo: + _ = credentials.retrieve_subject_token(None) + + assert excinfo.match(r"Executable returned unsupported version.") + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_expired_token(self): + EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_EXPIRED = { + "version": 1, + "success": True, + "token_type": "urn:ietf:params:oauth:token-type:id_token", + "id_token": self.EXECUTABLE_OIDC_TOKEN, + "expiration_time": 0, + } + + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess( + args=[], + stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_EXPIRED).encode( + "UTF-8" + ), + returncode=0, + ), + ): + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + + with pytest.raises(exceptions.RefreshError) as excinfo: + _ = credentials.retrieve_subject_token(None) + + assert excinfo.match(r"The token returned by the executable is expired.") + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_file_cache(self, tmpdir): + ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE = tmpdir.join( + "actual_output_file" + ) + ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE = { + "command": "command", + "timeout_millis": 30000, + "output_file": ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, + } + ACTUAL_CREDENTIAL_SOURCE = {"executable": ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE} + with open(ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, "w") as output_file: + json.dump(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN, output_file) + + credentials = self.make_pluggable(credential_source=ACTUAL_CREDENTIAL_SOURCE) + + subject_token = credentials.retrieve_subject_token(None) + assert subject_token == self.EXECUTABLE_OIDC_TOKEN + + os.remove(ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_no_file_cache(self): + ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE = { + "command": "command", + "timeout_millis": 30000, + } + ACTUAL_CREDENTIAL_SOURCE = {"executable": ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE} + + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess( + args=[], + stdout=json.dumps( + self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN + ).encode("UTF-8"), + returncode=0, + ), + ): + credentials = self.make_pluggable( + credential_source=ACTUAL_CREDENTIAL_SOURCE + ) + + subject_token = credentials.retrieve_subject_token(None) + + assert subject_token == self.EXECUTABLE_OIDC_TOKEN + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_file_cache_value_error_report(self, tmpdir): + ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE = tmpdir.join( + "actual_output_file" + ) + ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE = { + "command": "command", + "timeout_millis": 30000, + "output_file": ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, + } + ACTUAL_CREDENTIAL_SOURCE = {"executable": ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE} + ACTUAL_EXECUTABLE_RESPONSE = { + "success": True, + "token_type": "urn:ietf:params:oauth:token-type:id_token", + "id_token": self.EXECUTABLE_OIDC_TOKEN, + "expiration_time": 9999999999, + } + with open(ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, "w") as output_file: + json.dump(ACTUAL_EXECUTABLE_RESPONSE, output_file) + + credentials = self.make_pluggable(credential_source=ACTUAL_CREDENTIAL_SOURCE) + + with pytest.raises(ValueError) as excinfo: + _ = credentials.retrieve_subject_token(None) + + assert excinfo.match(r"The executable response is missing the version field.") + + os.remove(ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_file_cache_refresh_error_retry(self, tmpdir): + ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE = tmpdir.join( + "actual_output_file" + ) + ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE = { + "command": "command", + "timeout_millis": 30000, + "output_file": ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, + } + ACTUAL_CREDENTIAL_SOURCE = {"executable": ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE} + ACTUAL_EXECUTABLE_RESPONSE = { + "version": 2, + "success": True, + "token_type": "urn:ietf:params:oauth:token-type:id_token", + "id_token": self.EXECUTABLE_OIDC_TOKEN, + "expiration_time": 9999999999, + } + with open(ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, "w") as output_file: + json.dump(ACTUAL_EXECUTABLE_RESPONSE, output_file) + + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess( + args=[], + stdout=json.dumps( + self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN + ).encode("UTF-8"), + returncode=0, + ), + ): + credentials = self.make_pluggable( + credential_source=ACTUAL_CREDENTIAL_SOURCE + ) + + subject_token = credentials.retrieve_subject_token(None) + + assert subject_token == self.EXECUTABLE_OIDC_TOKEN + + os.remove(ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_unsupported_token_type(self): + EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { + "version": 1, + "success": True, + "token_type": "unsupported_token_type", + "id_token": self.EXECUTABLE_OIDC_TOKEN, + "expiration_time": 9999999999, + } + + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess( + args=[], + stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE).encode("UTF-8"), + returncode=0, + ), + ): + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + + with pytest.raises(exceptions.RefreshError) as excinfo: + _ = credentials.retrieve_subject_token(None) + + assert excinfo.match(r"Executable returned unsupported token type.") + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_missing_version(self): + EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { + "success": True, + "token_type": "urn:ietf:params:oauth:token-type:id_token", + "id_token": self.EXECUTABLE_OIDC_TOKEN, + "expiration_time": 9999999999, + } + + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess( + args=[], + stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE).encode("UTF-8"), + returncode=0, + ), + ): + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + + with pytest.raises(ValueError) as excinfo: + _ = credentials.retrieve_subject_token(None) + + assert excinfo.match( + r"The executable response is missing the version field." + ) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_missing_success(self): + EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { + "version": 1, + "token_type": "urn:ietf:params:oauth:token-type:id_token", + "id_token": self.EXECUTABLE_OIDC_TOKEN, + "expiration_time": 9999999999, + } + + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess( + args=[], + stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE).encode("UTF-8"), + returncode=0, + ), + ): + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + + with pytest.raises(ValueError) as excinfo: + _ = credentials.retrieve_subject_token(None) + + assert excinfo.match( + r"The executable response is missing the success field." + ) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_missing_error_code_message(self): + EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = {"version": 1, "success": False} + + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess( + args=[], + stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE).encode("UTF-8"), + returncode=0, + ), + ): + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + + with pytest.raises(ValueError) as excinfo: + _ = credentials.retrieve_subject_token(None) + + assert excinfo.match( + r"Error code and message fields are required in the response." + ) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_without_expiration_time_should_pass_when_output_file_not_specified( + self, + ): + EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { + "version": 1, + "success": True, + "token_type": "urn:ietf:params:oauth:token-type:id_token", + "id_token": self.EXECUTABLE_OIDC_TOKEN, + } + + CREDENTIAL_SOURCE = { + "executable": {"command": "command", "timeout_millis": 30000} + } + + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess( + args=[], + stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE).encode("UTF-8"), + returncode=0, + ), + ): + credentials = self.make_pluggable(credential_source=CREDENTIAL_SOURCE) + subject_token = credentials.retrieve_subject_token(None) + + assert subject_token == self.EXECUTABLE_OIDC_TOKEN + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_missing_token_type(self): + EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { + "version": 1, + "success": True, + "id_token": self.EXECUTABLE_OIDC_TOKEN, + "expiration_time": 9999999999, + } + + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess( + args=[], + stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE).encode("UTF-8"), + returncode=0, + ), + ): + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + + with pytest.raises(ValueError) as excinfo: + _ = credentials.retrieve_subject_token(None) + + assert excinfo.match( + r"The executable response is missing the token_type field." + ) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_credential_source_missing_command(self): + with pytest.raises(ValueError) as excinfo: + CREDENTIAL_SOURCE = { + "executable": { + "timeout_millis": 30000, + "output_file": self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, + } + } + _ = self.make_pluggable(credential_source=CREDENTIAL_SOURCE) + + assert excinfo.match( + r"Missing command field. Executable command must be provided." + ) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_credential_source_missing_output_interactive_mode(self): + CREDENTIAL_SOURCE = { + "executable": {"command": self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND} + } + credentials = self.make_pluggable( + credential_source=CREDENTIAL_SOURCE, interactive=True + ) + with pytest.raises(ValueError) as excinfo: + _ = credentials.retrieve_subject_token(None) + + assert excinfo.match( + r"An output_file must be specified in the credential configuration for interactive mode." + ) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_credential_source_timeout_missing_will_use_default_timeout_value(self): + CREDENTIAL_SOURCE = { + "executable": { + "command": self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND, + "output_file": self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, + } + } + credentials = self.make_pluggable(credential_source=CREDENTIAL_SOURCE) + + assert ( + credentials._credential_source_executable_timeout_millis + == pluggable.EXECUTABLE_TIMEOUT_MILLIS_DEFAULT + ) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_credential_source_timeout_small(self): + with pytest.raises(ValueError) as excinfo: + CREDENTIAL_SOURCE = { + "executable": { + "command": self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND, + "timeout_millis": 5000 - 1, + "output_file": self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, + } + } + _ = self.make_pluggable(credential_source=CREDENTIAL_SOURCE) + + assert excinfo.match(r"Timeout must be between 5 and 120 seconds.") + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_credential_source_timeout_large(self): + with pytest.raises(ValueError) as excinfo: + CREDENTIAL_SOURCE = { + "executable": { + "command": self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND, + "timeout_millis": 120000 + 1, + "output_file": self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, + } + } + _ = self.make_pluggable(credential_source=CREDENTIAL_SOURCE) + + assert excinfo.match(r"Timeout must be between 5 and 120 seconds.") + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_credential_source_interactive_timeout_small(self): + with pytest.raises(ValueError) as excinfo: + CREDENTIAL_SOURCE = { + "executable": { + "command": self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND, + "interactive_timeout_millis": 30000 - 1, + "output_file": self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, + } + } + _ = self.make_pluggable(credential_source=CREDENTIAL_SOURCE) + + assert excinfo.match( + r"Interactive timeout must be between 30 seconds and 30 minutes." + ) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_credential_source_interactive_timeout_large(self): + with pytest.raises(ValueError) as excinfo: + CREDENTIAL_SOURCE = { + "executable": { + "command": self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND, + "interactive_timeout_millis": 1800000 + 1, + "output_file": self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, + } + } + _ = self.make_pluggable(credential_source=CREDENTIAL_SOURCE) + + assert excinfo.match( + r"Interactive timeout must be between 30 seconds and 30 minutes." + ) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_executable_fail(self): + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess( + args=[], stdout=None, returncode=1 + ), + ): + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + + with pytest.raises(exceptions.RefreshError) as excinfo: + _ = credentials.retrieve_subject_token(None) + + assert excinfo.match( + r"Executable exited with non-zero return code 1. Error: None" + ) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_non_workforce_fail_interactive_mode(self): + credentials = self.make_pluggable( + credential_source=self.CREDENTIAL_SOURCE, interactive=True + ) + with pytest.raises(ValueError) as excinfo: + _ = credentials.retrieve_subject_token(None) + + assert excinfo.match(r"Interactive mode is only enabled for workforce pool.") + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_fail_on_validation_missing_interactive_timeout( + self, + ): + CREDENTIAL_SOURCE_EXECUTABLE = { + "command": self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND, + "output_file": self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, + } + CREDENTIAL_SOURCE = {"executable": CREDENTIAL_SOURCE_EXECUTABLE} + credentials = self.make_pluggable( + credential_source=CREDENTIAL_SOURCE, interactive=True + ) + with pytest.raises(ValueError) as excinfo: + _ = credentials.retrieve_subject_token(None) + + assert excinfo.match( + r"Interactive mode cannot run without an interactive timeout." + ) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_executable_fail_interactive_mode(self): + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess( + args=[], stdout=None, returncode=1 + ), + ): + credentials = self.make_pluggable( + audience=WORKFORCE_AUDIENCE, + credential_source=self.CREDENTIAL_SOURCE, + interactive=True, + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + _ = credentials.retrieve_subject_token(None) + + assert excinfo.match( + r"Executable exited with non-zero return code 1. Error: None" + ) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "0"}) + def test_revoke_failed_executable_not_allowed(self): + credentials = self.make_pluggable( + credential_source=self.CREDENTIAL_SOURCE, interactive=True + ) + with pytest.raises(ValueError) as excinfo: + _ = credentials.revoke(None) + + assert excinfo.match(r"Executables need to be explicitly allowed") + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_revoke_failed(self): + testData = { + "non_interactive_mode": { + "interactive": False, + "expectErrType": ValueError, + "expectErrPattern": r"Revoke is only enabled under interactive mode.", + }, + "executable_failed": { + "returncode": 1, + "expectErrType": exceptions.RefreshError, + "expectErrPattern": r"Auth revoke failed on executable.", + }, + "response_validation_missing_version": { + "response": {}, + "expectErrType": ValueError, + "expectErrPattern": r"The executable response is missing the version field.", + }, + "response_validation_invalid_version": { + "response": {"version": 2}, + "expectErrType": exceptions.RefreshError, + "expectErrPattern": r"Executable returned unsupported version.", + }, + "response_validation_missing_success": { + "response": {"version": 1}, + "expectErrType": ValueError, + "expectErrPattern": r"The executable response is missing the success field.", + }, + "response_validation_failed_with_success_field_is_false": { + "response": {"version": 1, "success": False}, + "expectErrType": exceptions.RefreshError, + "expectErrPattern": r"Revoke failed with unsuccessful response.", + }, + } + for data in testData.values(): + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess( + args=[], + stdout=json.dumps(data.get("response")).encode("UTF-8"), + returncode=data.get("returncode", 0), + ), + ): + credentials = self.make_pluggable( + audience=WORKFORCE_AUDIENCE, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + credential_source=self.CREDENTIAL_SOURCE, + interactive=data.get("interactive", True), + ) + + with pytest.raises(data.get("expectErrType")) as excinfo: + _ = credentials.revoke(None) + + assert excinfo.match(data.get("expectErrPattern")) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_revoke_successfully(self): + ACTUAL_RESPONSE = {"version": 1, "success": True} + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess( + args=[], + stdout=json.dumps(ACTUAL_RESPONSE).encode("utf-8"), + returncode=0, + ), + ): + credentials = self.make_pluggable( + audience=WORKFORCE_AUDIENCE, + credential_source=self.CREDENTIAL_SOURCE, + interactive=True, + ) + _ = credentials.revoke(None) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_python_2(self): + with mock.patch("sys.version_info", (2, 7)): + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + + with pytest.raises(exceptions.RefreshError) as excinfo: + _ = credentials.retrieve_subject_token(None) + + assert excinfo.match(r"Pluggable auth is only supported for python 3.7+") + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_with_quoted_command(self): + command_with_spaces = '"/path/with spaces/to/executable" "arg with spaces"' + credential_source = { + "executable": {"command": command_with_spaces, "timeout_millis": 30000} + } + + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess( + args=[], + stdout=json.dumps( + self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN + ).encode("UTF-8"), + returncode=0, + ), + ) as mock_run: + credentials = self.make_pluggable(credential_source=credential_source) + subject_token = credentials.retrieve_subject_token(None) + + assert subject_token == self.EXECUTABLE_OIDC_TOKEN + mock_run.assert_called_once_with( + ["/path/with spaces/to/executable", "arg with spaces"], + timeout=30.0, + stdin=None, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + env=mock.ANY, + ) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_revoke_subject_token_python_2(self): + with mock.patch("sys.version_info", (2, 7)): + credentials = self.make_pluggable( + audience=WORKFORCE_AUDIENCE, + credential_source=self.CREDENTIAL_SOURCE, + interactive=True, + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + _ = credentials.revoke(None) + + assert excinfo.match(r"Pluggable auth is only supported for python 3.7+") diff --git a/packages/google-auth/tests/transport/__init__.py b/packages/google-auth/tests/transport/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/google-auth/tests/transport/aio/test_aiohttp.py b/packages/google-auth/tests/transport/aio/test_aiohttp.py new file mode 100644 index 000000000000..632abff25a48 --- /dev/null +++ b/packages/google-auth/tests/transport/aio/test_aiohttp.py @@ -0,0 +1,170 @@ +# Copyright 2024 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 asyncio + +from aioresponses import aioresponses # type: ignore +from mock import AsyncMock, Mock, patch +import pytest # type: ignore +import pytest_asyncio # type: ignore + +from google.auth import exceptions +import google.auth.aio.transport.aiohttp as auth_aiohttp + + +try: + import aiohttp # type: ignore +except ImportError as caught_exc: # pragma: NO COVER + raise ImportError( + "The aiohttp library is not installed from please install the aiohttp package to use the aiohttp transport." + ) from caught_exc + + +@pytest.fixture +def mock_response(): + response = Mock() + response.status = 200 + response.headers = {"Content-Type": "application/json", "Content-Length": "100"} + mock_iterator = AsyncMock() + mock_iterator.__aiter__.return_value = iter( + [b"Cavefish ", b"have ", b"no ", b"sight."] + ) + response.content.iter_chunked = lambda chunk_size: mock_iterator + response.read = AsyncMock(return_value=b"Cavefish have no sight.") + response.close = AsyncMock() + + return auth_aiohttp.Response(response) + + +class TestResponse(object): + @pytest.mark.asyncio + async def test_response_status_code(self, mock_response): + assert mock_response.status_code == 200 + + @pytest.mark.asyncio + async def test_response_headers(self, mock_response): + assert mock_response.headers["Content-Type"] == "application/json" + assert mock_response.headers["Content-Length"] == "100" + + @pytest.mark.asyncio + async def test_response_content(self, mock_response): + content = b"".join([chunk async for chunk in mock_response.content()]) + assert content == b"Cavefish have no sight." + + @pytest.mark.asyncio + async def test_response_content_raises_error(self, mock_response): + with patch.object( + mock_response._response.content, + "iter_chunked", + side_effect=aiohttp.ClientPayloadError, + ): + with pytest.raises(exceptions.ResponseError) as exc: + [chunk async for chunk in mock_response.content()] + exc.match("Failed to read from the payload stream") + + @pytest.mark.asyncio + async def test_response_read(self, mock_response): + content = await mock_response.read() + assert content == b"Cavefish have no sight." + + @pytest.mark.asyncio + async def test_response_read_raises_error(self, mock_response): + with patch.object( + mock_response._response, + "read", + side_effect=aiohttp.ClientResponseError(None, None), + ): + with pytest.raises(exceptions.ResponseError) as exc: + await mock_response.read() + exc.match("Failed to read the response body.") + + @pytest.mark.asyncio + async def test_response_close(self, mock_response): + await mock_response.close() + mock_response._response.close.assert_called_once() + + @pytest.mark.asyncio + async def test_response_content_stream(self, mock_response): + itr = mock_response.content().__aiter__() + content = [] + try: + while True: + chunk = await itr.__anext__() + content.append(chunk) + except StopAsyncIteration: + pass + assert b"".join(content) == b"Cavefish have no sight." + + +@pytest.mark.asyncio +class TestRequest: + @pytest_asyncio.fixture + async def aiohttp_request(self): + request = auth_aiohttp.Request() + yield request + await request.close() + + async def test_request_call_success(self, aiohttp_request): + with aioresponses() as m: + mocked_chunks = [b"Cavefish ", b"have ", b"no ", b"sight."] + mocked_response = b"".join(mocked_chunks) + m.get("http://example.com", status=200, body=mocked_response) + response = await aiohttp_request("http://example.com") + assert response.status_code == 200 + assert response.headers == {"Content-Type": "application/json"} + content = b"".join([chunk async for chunk in response.content()]) + assert content == b"Cavefish have no sight." + + async def test_request_call_success_with_provided_session(self): + mock_session = aiohttp.ClientSession() + request = auth_aiohttp.Request(mock_session) + with aioresponses() as m: + mocked_chunks = [b"Cavefish ", b"have ", b"no ", b"sight."] + mocked_response = b"".join(mocked_chunks) + m.get("http://example.com", status=200, body=mocked_response) + response = await request("http://example.com") + assert response.status_code == 200 + assert response.headers == {"Content-Type": "application/json"} + content = b"".join([chunk async for chunk in response.content()]) + assert content == b"Cavefish have no sight." + + async def test_request_call_raises_client_error(self, aiohttp_request): + with aioresponses() as m: + m.get("http://example.com", exception=aiohttp.ClientError) + + with pytest.raises(exceptions.TransportError) as exc: + await aiohttp_request("http://example.com/api") + + exc.match("Failed to send request to http://example.com/api.") + + async def test_request_call_raises_timeout_error(self, aiohttp_request): + with aioresponses() as m: + m.get("http://example.com", exception=asyncio.TimeoutError) + + with pytest.raises(exceptions.TimeoutError) as exc: + await aiohttp_request("http://example.com") + + exc.match("Request timed out after 180 seconds.") + + async def test_request_call_raises_transport_error_for_closed_session( + self, aiohttp_request + ): + with aioresponses() as m: + m.get("http://example.com", exception=asyncio.TimeoutError) + aiohttp_request._closed = True + with pytest.raises(exceptions.TransportError) as exc: + await aiohttp_request("http://example.com") + + exc.match("session is closed.") + aiohttp_request._closed = False diff --git a/packages/google-auth/tests/transport/aio/test_sessions.py b/packages/google-auth/tests/transport/aio/test_sessions.py new file mode 100644 index 000000000000..742f863d0f97 --- /dev/null +++ b/packages/google-auth/tests/transport/aio/test_sessions.py @@ -0,0 +1,321 @@ +# Copyright 2024 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 asyncio +from typing import AsyncGenerator + +from aioresponses import aioresponses # type: ignore +from mock import Mock, patch +import pytest # type: ignore + +from google.auth.aio.credentials import AnonymousCredentials +from google.auth.aio.transport import ( + _DEFAULT_TIMEOUT_SECONDS, + DEFAULT_MAX_RETRY_ATTEMPTS, + DEFAULT_RETRYABLE_STATUS_CODES, + Request, + Response, + sessions, +) +from google.auth.exceptions import InvalidType, TimeoutError, TransportError + + +@pytest.fixture +def simple_async_task(): + # Wrap async fixture within a synchronous fixture to suppress pytest.PytestRemovedIn9Warning + # See https://docs.pytest.org/en/stable/deprecations.html#sync-test-depending-on-async-fixture + async def inner_fixture(): + return True + + return inner_fixture() + + +class MockRequest(Request): + def __init__(self, response=None, side_effect=None): + self._closed = False + self._response = response + self._side_effect = side_effect + self.call_count = 0 + + async def __call__( + self, + url, + method="GET", + body=None, + headers=None, + timeout=_DEFAULT_TIMEOUT_SECONDS, + **kwargs, + ): + self.call_count += 1 + if self._side_effect: + raise self._side_effect + return self._response + + async def close(self): + self._closed = True + return None + + +class MockResponse(Response): + def __init__(self, status_code, headers=None, content=None): + self._status_code = status_code + self._headers = headers + self._content = content + self._close = False + + @property + def status_code(self): + return self._status_code + + @property + def headers(self): + return self._headers + + async def read(self) -> bytes: + content = await self.content(1024) + return b"".join([chunk async for chunk in content]) + + async def content(self, chunk_size=None) -> AsyncGenerator: + return self._content + + async def close(self) -> None: + self._close = True + + +class TestTimeoutGuard(object): + default_timeout = 1 + + def make_timeout_guard(self, timeout): + return sessions.timeout_guard(timeout) + + @pytest.mark.asyncio + async def test_timeout_with_simple_async_task_within_bounds( + self, simple_async_task + ): + task = False + with patch("time.monotonic", side_effect=[0, 0.25, 0.75]): + with patch("asyncio.wait_for", lambda coro, _: coro): + async with self.make_timeout_guard( + timeout=self.default_timeout + ) as with_timeout: + task = await with_timeout(simple_async_task) + + # Task succeeds. + assert task is True + + @pytest.mark.asyncio + async def test_timeout_with_simple_async_task_out_of_bounds( + self, simple_async_task + ): + task = False + with patch("time.monotonic", side_effect=[0, 1, 1]): + with pytest.raises(TimeoutError) as exc: + async with self.make_timeout_guard( + timeout=self.default_timeout + ) as with_timeout: + task = await with_timeout(simple_async_task) + + # Task does not succeed and the context manager times out i.e. no remaining time left. + assert task is False + assert exc.match( + f"Context manager exceeded the configured timeout of {self.default_timeout}s." + ) + + @pytest.mark.asyncio + async def test_timeout_with_async_task_timing_out_before_context( + self, simple_async_task + ): + task = False + with pytest.raises(TimeoutError) as exc: + async with self.make_timeout_guard( + timeout=self.default_timeout + ) as with_timeout: + with patch("asyncio.wait_for", side_effect=asyncio.TimeoutError): + task = await with_timeout(simple_async_task) + + # Task does not complete i.e. the operation times out. + assert task is False + assert exc.match( + f"The operation {simple_async_task} exceeded the configured timeout of {self.default_timeout}s." + ) + + +class TestAsyncAuthorizedSession(object): + TEST_URL = "http://example.com/" + credentials = AnonymousCredentials() + + @pytest.fixture + def mocked_content(self): + # Wrap async fixture within a synchronous fixture to suppress pytest.PytestRemovedIn9Warning + # See https://docs.pytest.org/en/stable/deprecations.html#sync-test-depending-on-async-fixture + async def inner_fixture(): + content = [b"Cavefish ", b"have ", b"no ", b"sight."] + for chunk in content: + yield chunk + + return inner_fixture() + + @pytest.mark.asyncio + async def test_constructor_with_default_auth_request(self): + with patch("google.auth.aio.transport.sessions.AIOHTTP_INSTALLED", True): + authed_session = sessions.AsyncAuthorizedSession(self.credentials) + assert authed_session._credentials == self.credentials + await authed_session.close() + + @pytest.mark.asyncio + async def test_constructor_with_provided_auth_request(self): + auth_request = MockRequest() + authed_session = sessions.AsyncAuthorizedSession( + self.credentials, auth_request=auth_request + ) + + assert authed_session._auth_request is auth_request + await authed_session.close() + + @pytest.mark.asyncio + async def test_constructor_raises_no_auth_request_error(self): + with patch("google.auth.aio.transport.sessions.AIOHTTP_INSTALLED", False): + with pytest.raises(TransportError) as exc: + sessions.AsyncAuthorizedSession(self.credentials) + + exc.match( + "`auth_request` must either be configured or the external package `aiohttp` must be installed to use the default value." + ) + + @pytest.mark.asyncio + async def test_constructor_raises_incorrect_credentials_error(self): + credentials = Mock() + with pytest.raises(InvalidType) as exc: + sessions.AsyncAuthorizedSession(credentials) + + exc.match( + f"The configured credentials of type {type(credentials)} are invalid and must be of type `google.auth.aio.credentials.Credentials`" + ) + + @pytest.mark.asyncio + async def test_request_default_auth_request_success(self): + with aioresponses() as m: + mocked_chunks = [b"Cavefish ", b"have ", b"no ", b"sight."] + mocked_response = b"".join(mocked_chunks) + m.get(self.TEST_URL, status=200, body=mocked_response) + authed_session = sessions.AsyncAuthorizedSession(self.credentials) + response = await authed_session.request("GET", self.TEST_URL) + assert response.status_code == 200 + assert response.headers == {"Content-Type": "application/json"} + assert await response.read() == b"Cavefish have no sight." + await response.close() + + await authed_session.close() + + @pytest.mark.asyncio + async def test_request_provided_auth_request_success(self, mocked_content): + mocked_response = MockResponse( + status_code=200, + headers={"Content-Type": "application/json"}, + content=mocked_content, + ) + auth_request = MockRequest(mocked_response) + authed_session = sessions.AsyncAuthorizedSession(self.credentials, auth_request) + response = await authed_session.request("GET", self.TEST_URL) + assert response.status_code == 200 + assert response.headers == {"Content-Type": "application/json"} + assert await response.read() == b"Cavefish have no sight." + await response.close() + assert response._close + + await authed_session.close() + + @pytest.mark.asyncio + async def test_request_raises_timeout_error(self): + auth_request = MockRequest(side_effect=asyncio.TimeoutError) + authed_session = sessions.AsyncAuthorizedSession(self.credentials, auth_request) + with pytest.raises(TimeoutError): + await authed_session.request("GET", self.TEST_URL) + + @pytest.mark.asyncio + async def test_request_raises_transport_error(self): + auth_request = MockRequest(side_effect=TransportError) + authed_session = sessions.AsyncAuthorizedSession(self.credentials, auth_request) + with pytest.raises(TransportError): + await authed_session.request("GET", self.TEST_URL) + + @pytest.mark.asyncio + async def test_request_max_allowed_time_exceeded_error(self): + auth_request = MockRequest(side_effect=TransportError) + authed_session = sessions.AsyncAuthorizedSession(self.credentials, auth_request) + with patch("time.monotonic", side_effect=[0, 1, 1]): + with pytest.raises(TimeoutError): + await authed_session.request("GET", self.TEST_URL, max_allowed_time=1) + + @pytest.mark.parametrize("retry_status", DEFAULT_RETRYABLE_STATUS_CODES) + @pytest.mark.asyncio + async def test_request_max_retries(self, retry_status): + mocked_response = MockResponse(status_code=retry_status) + auth_request = MockRequest(mocked_response) + with patch("asyncio.sleep", return_value=None): + authed_session = sessions.AsyncAuthorizedSession( + self.credentials, auth_request + ) + await authed_session.request("GET", self.TEST_URL) + assert auth_request.call_count == DEFAULT_MAX_RETRY_ATTEMPTS + + @pytest.mark.asyncio + async def test_http_get_method_success(self): + expected_payload = b"content is retrieved." + authed_session = sessions.AsyncAuthorizedSession(self.credentials) + with aioresponses() as m: + m.get(self.TEST_URL, status=200, body=expected_payload) + response = await authed_session.get(self.TEST_URL) + assert await response.read() == expected_payload + response = await authed_session.close() + + @pytest.mark.asyncio + async def test_http_post_method_success(self): + expected_payload = b"content is posted." + authed_session = sessions.AsyncAuthorizedSession(self.credentials) + with aioresponses() as m: + m.post(self.TEST_URL, status=200, body=expected_payload) + response = await authed_session.post(self.TEST_URL) + assert await response.read() == expected_payload + response = await authed_session.close() + + @pytest.mark.asyncio + async def test_http_put_method_success(self): + expected_payload = b"content is retrieved." + authed_session = sessions.AsyncAuthorizedSession(self.credentials) + with aioresponses() as m: + m.put(self.TEST_URL, status=200, body=expected_payload) + response = await authed_session.put(self.TEST_URL) + assert await response.read() == expected_payload + response = await authed_session.close() + + @pytest.mark.asyncio + async def test_http_patch_method_success(self): + expected_payload = b"content is retrieved." + authed_session = sessions.AsyncAuthorizedSession(self.credentials) + with aioresponses() as m: + m.patch(self.TEST_URL, status=200, body=expected_payload) + response = await authed_session.patch(self.TEST_URL) + assert await response.read() == expected_payload + response = await authed_session.close() + + @pytest.mark.asyncio + async def test_http_delete_method_success(self): + expected_payload = b"content is deleted." + authed_session = sessions.AsyncAuthorizedSession(self.credentials) + with aioresponses() as m: + m.delete(self.TEST_URL, status=200, body=expected_payload) + response = await authed_session.delete(self.TEST_URL) + assert await response.read() == expected_payload + response = await authed_session.close() diff --git a/packages/google-auth/tests/transport/compliance.py b/packages/google-auth/tests/transport/compliance.py new file mode 100644 index 000000000000..b3cd7e823469 --- /dev/null +++ b/packages/google-auth/tests/transport/compliance.py @@ -0,0 +1,108 @@ +# Copyright 2016 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 http.client as http_client +import time + +import flask # type: ignore +import pytest # type: ignore +from pytest_localserver.http import WSGIServer # type: ignore + +from google.auth import exceptions + +# .invalid will never resolve, see https://tools.ietf.org/html/rfc2606 +NXDOMAIN = "test.invalid" + + +class RequestResponseTests(object): + @pytest.fixture(scope="module") + def server(self): + """Provides a test HTTP server. + + The test server is automatically created before + a test and destroyed at the end. The server is serving a test + application that can be used to verify requests. + """ + app = flask.Flask(__name__) + app.debug = True + + # pylint: disable=unused-variable + # (pylint thinks the flask routes are unusued.) + @app.route("/basic") + def index(): + header_value = flask.request.headers.get("x-test-header", "value") + headers = {"X-Test-Header": header_value} + return "Basic Content", http_client.OK, headers + + @app.route("/server_error") + def server_error(): + return "Error", http_client.INTERNAL_SERVER_ERROR + + @app.route("/wait") + def wait(): + time.sleep(3) + return "Waited" + + # pylint: enable=unused-variable + + server = WSGIServer(application=app.wsgi_app) + server.start() + yield server + server.stop() + + def test_request_basic(self, server): + request = self.make_request() + response = request(url=server.url + "/basic", method="GET") + + assert response.status == http_client.OK + assert response.headers["x-test-header"] == "value" + assert response.data == b"Basic Content" + + def test_request_with_timeout_success(self, server): + request = self.make_request() + response = request(url=server.url + "/basic", method="GET", timeout=2) + + assert response.status == http_client.OK + assert response.headers["x-test-header"] == "value" + assert response.data == b"Basic Content" + + def test_request_with_timeout_failure(self, server): + request = self.make_request() + + with pytest.raises(exceptions.TransportError): + request(url=server.url + "/wait", method="GET", timeout=1) + + def test_request_headers(self, server): + request = self.make_request() + response = request( + url=server.url + "/basic", + method="GET", + headers={"x-test-header": "hello world"}, + ) + + assert response.status == http_client.OK + assert response.headers["x-test-header"] == "hello world" + assert response.data == b"Basic Content" + + def test_request_error(self, server): + request = self.make_request() + response = request(url=server.url + "/server_error", method="GET") + + assert response.status == http_client.INTERNAL_SERVER_ERROR + assert response.data == b"Error" + + def test_connection_error(self): + request = self.make_request() + with pytest.raises(exceptions.TransportError): + request(url="http://{}".format(NXDOMAIN), method="GET") diff --git a/packages/google-auth/tests/transport/test__custom_tls_signer.py b/packages/google-auth/tests/transport/test__custom_tls_signer.py new file mode 100644 index 000000000000..3a33c2c02154 --- /dev/null +++ b/packages/google-auth/tests/transport/test__custom_tls_signer.py @@ -0,0 +1,262 @@ +# 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. +import base64 +import ctypes +import os + +import mock +import pytest # type: ignore +from requests.packages.urllib3.util.ssl_ import create_urllib3_context # type: ignore +import urllib3.contrib.pyopenssl # type: ignore + +from google.auth import exceptions +from google.auth.transport import _custom_tls_signer + +urllib3.contrib.pyopenssl.inject_into_urllib3() + +FAKE_ENTERPRISE_CERT_FILE_PATH = "/path/to/enterprise/cert/file" +ENTERPRISE_CERT_FILE = os.path.join( + os.path.dirname(__file__), "../data/enterprise_cert_valid.json" +) +ENTERPRISE_CERT_FILE_PROVIDER = os.path.join( + os.path.dirname(__file__), "../data/enterprise_cert_valid_provider.json" +) +INVALID_ENTERPRISE_CERT_FILE = os.path.join( + os.path.dirname(__file__), "../data/enterprise_cert_invalid.json" +) + + +def test_load_provider_lib(): + with mock.patch("ctypes.CDLL", return_value=mock.MagicMock()): + _custom_tls_signer.load_provider_lib("/path/to/provider/lib") + + +def test_load_offload_lib(): + with mock.patch("ctypes.CDLL", return_value=mock.MagicMock()): + lib = _custom_tls_signer.load_offload_lib("/path/to/offload/lib") + + assert lib.ConfigureSslContext.argtypes == [ + _custom_tls_signer.SIGN_CALLBACK_CTYPE, + ctypes.c_char_p, + ctypes.c_void_p, + ] + assert lib.ConfigureSslContext.restype == ctypes.c_int + + +def test_load_signer_lib(): + with mock.patch("ctypes.CDLL", return_value=mock.MagicMock()): + lib = _custom_tls_signer.load_signer_lib("/path/to/signer/lib") + + assert lib.SignForPython.restype == ctypes.c_int + assert lib.SignForPython.argtypes == [ + ctypes.c_char_p, + ctypes.c_char_p, + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_int, + ] + + assert lib.GetCertPemForPython.restype == ctypes.c_int + assert lib.GetCertPemForPython.argtypes == [ + ctypes.c_char_p, + ctypes.c_char_p, + ctypes.c_int, + ] + + +def test__compute_sha256_digest(): + to_be_signed = ctypes.create_string_buffer(b"foo") + sig = _custom_tls_signer._compute_sha256_digest(to_be_signed, 4) + + assert ( + base64.b64encode(sig).decode() == "RG5gyEH8CAAh3lxgbt2PLPAHPO8p6i9+cn5dqHfUUYM=" + ) + + +def test_get_sign_callback(): + # mock signer lib's SignForPython function + mock_sig_len = 10 + mock_signer_lib = mock.MagicMock() + mock_signer_lib.SignForPython.return_value = mock_sig_len + + # create a sign callback. The callback calls signer lib's SignForPython method + sign_callback = _custom_tls_signer.get_sign_callback( + mock_signer_lib, FAKE_ENTERPRISE_CERT_FILE_PATH + ) + + # mock the parameters used to call the sign callback + to_be_signed = ctypes.POINTER(ctypes.c_ubyte)() + to_be_signed_len = 4 + returned_sig_array = ctypes.c_ubyte() + mock_sig_array = ctypes.byref(returned_sig_array) + returned_sign_len = ctypes.c_ulong() + mock_sig_len_array = ctypes.byref(returned_sign_len) + + # call the callback, make sure the signature len is returned via mock_sig_len_array[0] + assert sign_callback( + mock_sig_array, mock_sig_len_array, to_be_signed, to_be_signed_len + ) + assert returned_sign_len.value == mock_sig_len + + +def test_get_sign_callback_failed_to_sign(): + # mock signer lib's SignForPython function. Set the sig len to be 0 to + # indicate the signing failed. + mock_sig_len = 0 + mock_signer_lib = mock.MagicMock() + mock_signer_lib.SignForPython.return_value = mock_sig_len + + # create a sign callback. The callback calls signer lib's SignForPython method + sign_callback = _custom_tls_signer.get_sign_callback( + mock_signer_lib, FAKE_ENTERPRISE_CERT_FILE_PATH + ) + + # mock the parameters used to call the sign callback + to_be_signed = ctypes.POINTER(ctypes.c_ubyte)() + to_be_signed_len = 4 + returned_sig_array = ctypes.c_ubyte() + mock_sig_array = ctypes.byref(returned_sig_array) + returned_sign_len = ctypes.c_ulong() + mock_sig_len_array = ctypes.byref(returned_sign_len) + sign_callback(mock_sig_array, mock_sig_len_array, to_be_signed, to_be_signed_len) + + # sign callback should return 0 + assert not sign_callback( + mock_sig_array, mock_sig_len_array, to_be_signed, to_be_signed_len + ) + + +def test_get_cert_no_cert(): + # mock signer lib's GetCertPemForPython function to return 0 to indicts + # the cert doesn't exit (cert len = 0) + mock_signer_lib = mock.MagicMock() + mock_signer_lib.GetCertPemForPython.return_value = 0 + + # call the get cert method + with pytest.raises(exceptions.MutualTLSChannelError) as excinfo: + _custom_tls_signer.get_cert(mock_signer_lib, FAKE_ENTERPRISE_CERT_FILE_PATH) + + assert excinfo.match("failed to get certificate") + + +def test_get_cert(): + # mock signer lib's GetCertPemForPython function + mock_cert_len = 10 + mock_signer_lib = mock.MagicMock() + mock_signer_lib.GetCertPemForPython.return_value = mock_cert_len + + # call the get cert method + mock_cert = _custom_tls_signer.get_cert( + mock_signer_lib, FAKE_ENTERPRISE_CERT_FILE_PATH + ) + + # make sure the signer lib's GetCertPemForPython is called twice, and the + # mock_cert has length mock_cert_len + assert mock_signer_lib.GetCertPemForPython.call_count == 2 + assert len(mock_cert) == mock_cert_len + + +def test_custom_tls_signer(): + offload_lib = mock.MagicMock() + signer_lib = mock.MagicMock() + + # Test load_libraries method + with mock.patch( + "google.auth.transport._custom_tls_signer.load_signer_lib" + ) as load_signer_lib: + with mock.patch( + "google.auth.transport._custom_tls_signer.load_offload_lib" + ) as load_offload_lib: + load_offload_lib.return_value = offload_lib + load_signer_lib.return_value = signer_lib + with mock.patch( + "google.auth.transport._custom_tls_signer.get_cert" + ) as get_cert: + with mock.patch( + "google.auth.transport._custom_tls_signer.get_sign_callback" + ) as get_sign_callback: + get_cert.return_value = b"mock_cert" + signer_object = _custom_tls_signer.CustomTlsSigner( + ENTERPRISE_CERT_FILE + ) + signer_object.load_libraries() + signer_object.attach_to_ssl_context(create_urllib3_context()) + get_cert.assert_called_once() + get_sign_callback.assert_called_once() + offload_lib.ConfigureSslContext.assert_called_once() + assert not signer_object.should_use_provider() + assert signer_object._enterprise_cert_file_path == ENTERPRISE_CERT_FILE + assert signer_object._offload_lib == offload_lib + assert signer_object._signer_lib == signer_lib + load_signer_lib.assert_called_with("/path/to/signer/lib") + load_offload_lib.assert_called_with("/path/to/offload/lib") + + +def test_custom_tls_signer_provider(): + provider_lib = mock.MagicMock() + + # Test load_libraries method + with mock.patch( + "google.auth.transport._custom_tls_signer.load_provider_lib" + ) as load_provider_lib: + load_provider_lib.return_value = provider_lib + signer_object = _custom_tls_signer.CustomTlsSigner( + ENTERPRISE_CERT_FILE_PROVIDER + ) + signer_object.load_libraries() + signer_object.attach_to_ssl_context(mock.MagicMock()) + + assert signer_object.should_use_provider() + assert signer_object._enterprise_cert_file_path == ENTERPRISE_CERT_FILE_PROVIDER + assert signer_object._provider_lib == provider_lib + load_provider_lib.assert_called_with("/path/to/provider/lib") + + +def test_custom_tls_signer_failed_to_load_libraries(): + with pytest.raises(exceptions.MutualTLSChannelError) as excinfo: + signer_object = _custom_tls_signer.CustomTlsSigner(INVALID_ENTERPRISE_CERT_FILE) + signer_object.load_libraries() + assert excinfo.match("enterprise cert file is invalid") + + +def test_custom_tls_signer_failed_to_attach(): + with pytest.raises(exceptions.MutualTLSChannelError) as excinfo: + signer_object = _custom_tls_signer.CustomTlsSigner(ENTERPRISE_CERT_FILE) + signer_object._offload_lib = mock.MagicMock() + signer_object._signer_lib = mock.MagicMock() + signer_object._sign_callback = mock.MagicMock() + signer_object._cert = b"mock cert" + signer_object._offload_lib.ConfigureSslContext.return_value = False + signer_object.attach_to_ssl_context(mock.MagicMock()) + assert excinfo.match("failed to configure ECP Offload SSL context") + + +def test_custom_tls_signer_failed_to_attach_provider(): + with pytest.raises(exceptions.MutualTLSChannelError) as excinfo: + signer_object = _custom_tls_signer.CustomTlsSigner( + ENTERPRISE_CERT_FILE_PROVIDER + ) + signer_object._provider_lib = mock.MagicMock() + signer_object._provider_lib.ECP_attach_to_ctx.return_value = False + signer_object.attach_to_ssl_context(mock.MagicMock()) + assert excinfo.match("failed to configure ECP Provider SSL context") + + +def test_custom_tls_signer_failed_to_attach_no_libs(): + with pytest.raises(exceptions.MutualTLSChannelError) as excinfo: + signer_object = _custom_tls_signer.CustomTlsSigner(ENTERPRISE_CERT_FILE) + signer_object._offload_lib = None + signer_object._signer_lib = None + signer_object.attach_to_ssl_context(mock.MagicMock()) + assert excinfo.match("Invalid ECP configuration.") diff --git a/packages/google-auth/tests/transport/test__http_client.py b/packages/google-auth/tests/transport/test__http_client.py new file mode 100644 index 000000000000..202276323c66 --- /dev/null +++ b/packages/google-auth/tests/transport/test__http_client.py @@ -0,0 +1,31 @@ +# Copyright 2016 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 # type: ignore + +from google.auth import exceptions +import google.auth.transport._http_client +from tests.transport import compliance + + +class TestRequestResponse(compliance.RequestResponseTests): + def make_request(self): + return google.auth.transport._http_client.Request() + + def test_non_http(self): + request = self.make_request() + with pytest.raises(exceptions.TransportError) as excinfo: + request(url="https://{}".format(compliance.NXDOMAIN), method="GET") + + assert excinfo.match("https") diff --git a/packages/google-auth/tests/transport/test__mtls_helper.py b/packages/google-auth/tests/transport/test__mtls_helper.py new file mode 100644 index 000000000000..01d5e3a40937 --- /dev/null +++ b/packages/google-auth/tests/transport/test__mtls_helper.py @@ -0,0 +1,717 @@ +# 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 json +import os +import re + +import mock +from OpenSSL import crypto +import pytest # type: ignore + +from google.auth import exceptions +from google.auth.transport import _mtls_helper + +CONTEXT_AWARE_METADATA = {"cert_provider_command": ["some command"]} + +ENCRYPTED_EC_PRIVATE_KEY = b"""-----BEGIN ENCRYPTED PRIVATE KEY----- +MIHkME8GCSqGSIb3DQEFDTBCMCkGCSqGSIb3DQEFDDAcBAgl2/yVgs1h3QICCAAw +DAYIKoZIhvcNAgkFADAVBgkrBgEEAZdVAQIECJk2GRrvxOaJBIGQXIBnMU4wmciT +uA6yD8q0FxuIzjG7E2S6tc5VRgSbhRB00eBO3jWmO2pBybeQW+zVioDcn50zp2ts +wYErWC+LCm1Zg3r+EGnT1E1GgNoODbVQ3AEHlKh1CGCYhEovxtn3G+Fjh7xOBrNB +saVVeDb4tHD4tMkiVVUBrUcTZPndP73CtgyGHYEphasYPzEz3+AU +-----END ENCRYPTED PRIVATE KEY-----""" + +EC_PUBLIC_KEY = b"""-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEvCNi1NoDY1oMqPHIgXI8RBbTYGi/ +brEjbre1nSiQW11xRTJbVeETdsuP0EAu2tG3PcRhhwDfeJ8zXREgTBurNw== +-----END PUBLIC KEY-----""" + +PASSPHRASE = b"""-----BEGIN PASSPHRASE----- +password +-----END PASSPHRASE-----""" +PASSPHRASE_VALUE = b"password" + + +def check_cert_and_key(content, expected_cert, expected_key): + success = True + + cert_match = re.findall(_mtls_helper._CERT_REGEX, content) + success = success and len(cert_match) == 1 and cert_match[0] == expected_cert + + key_match = re.findall(_mtls_helper._KEY_REGEX, content) + success = success and len(key_match) == 1 and key_match[0] == expected_key + + return success + + +class TestCertAndKeyRegex(object): + def test_cert_and_key(self): + # Test single cert and single key + check_cert_and_key( + pytest.public_cert_bytes + pytest.private_key_bytes, + pytest.public_cert_bytes, + pytest.private_key_bytes, + ) + check_cert_and_key( + pytest.private_key_bytes + pytest.public_cert_bytes, + pytest.public_cert_bytes, + pytest.private_key_bytes, + ) + + # Test cert chain and single key + check_cert_and_key( + pytest.public_cert_bytes + + pytest.public_cert_bytes + + pytest.private_key_bytes, + pytest.public_cert_bytes + pytest.public_cert_bytes, + pytest.private_key_bytes, + ) + check_cert_and_key( + pytest.private_key_bytes + + pytest.public_cert_bytes + + pytest.public_cert_bytes, + pytest.public_cert_bytes + pytest.public_cert_bytes, + pytest.private_key_bytes, + ) + + def test_key(self): + # Create some fake keys for regex check. + KEY = b"""-----BEGIN PRIVATE KEY----- + MIIBCgKCAQEA4ej0p7bQ7L/r4rVGUz9RN4VQWoej1Bg1mYWIDYslvKrk1gpj7wZg + /fy3ZpsL7WqgsZS7Q+0VRK8gKfqkxg5OYQIDAQAB + -----END PRIVATE KEY-----""" + RSA_KEY = b"""-----BEGIN RSA PRIVATE KEY----- + MIIBCgKCAQEA4ej0p7bQ7L/r4rVGUz9RN4VQWoej1Bg1mYWIDYslvKrk1gpj7wZg + /fy3ZpsL7WqgsZS7Q+0VRK8gKfqkxg5OYQIDAQAB + -----END RSA PRIVATE KEY-----""" + EC_KEY = b"""-----BEGIN EC PRIVATE KEY----- + MIIBCgKCAQEA4ej0p7bQ7L/r4rVGUz9RN4VQWoej1Bg1mYWIDYslvKrk1gpj7wZg + /fy3ZpsL7WqgsZS7Q+0VRK8gKfqkxg5OYQIDAQAB + -----END EC PRIVATE KEY-----""" + + check_cert_and_key( + pytest.public_cert_bytes + KEY, pytest.public_cert_bytes, KEY + ) + check_cert_and_key( + pytest.public_cert_bytes + RSA_KEY, pytest.public_cert_bytes, RSA_KEY + ) + check_cert_and_key( + pytest.public_cert_bytes + EC_KEY, pytest.public_cert_bytes, EC_KEY + ) + + +class TestCheckConfigPath(object): + def test_success(self): + metadata_path = os.path.join(pytest.data_dir, "context_aware_metadata.json") + returned_path = _mtls_helper._check_config_path(metadata_path) + assert returned_path is not None + + def test_failure(self): + metadata_path = os.path.join(pytest.data_dir, "not_exists.json") + returned_path = _mtls_helper._check_config_path(metadata_path) + assert returned_path is None + + +class TestReadMetadataFile(object): + def test_success(self): + metadata_path = os.path.join(pytest.data_dir, "context_aware_metadata.json") + metadata = _mtls_helper._load_json_file(metadata_path) + + assert "cert_provider_command" in metadata + + def test_file_not_json(self): + # read a file which is not json format. + metadata_path = os.path.join(pytest.data_dir, "privatekey.pem") + with pytest.raises(exceptions.ClientCertError): + _mtls_helper._load_json_file(metadata_path) + + +class TestRunCertProviderCommand(object): + def create_mock_process(self, output, error): + # There are two steps to execute a script with subprocess.Popen. + # (1) process = subprocess.Popen([comannds]) + # (2) stdout, stderr = process.communicate() + # This function creates a mock process which can be returned by a mock + # subprocess.Popen. The mock process returns the given output and error + # when mock_process.communicate() is called. + mock_process = mock.Mock() + attrs = {"communicate.return_value": (output, error), "returncode": 0} + mock_process.configure_mock(**attrs) + return mock_process + + @mock.patch("subprocess.Popen", autospec=True) + def test_success(self, mock_popen): + mock_popen.return_value = self.create_mock_process( + pytest.public_cert_bytes + pytest.private_key_bytes, b"" + ) + cert, key, passphrase = _mtls_helper._run_cert_provider_command(["command"]) + assert cert == pytest.public_cert_bytes + assert key == pytest.private_key_bytes + assert passphrase is None + + mock_popen.return_value = self.create_mock_process( + pytest.public_cert_bytes + ENCRYPTED_EC_PRIVATE_KEY + PASSPHRASE, b"" + ) + cert, key, passphrase = _mtls_helper._run_cert_provider_command( + ["command"], expect_encrypted_key=True + ) + assert cert == pytest.public_cert_bytes + assert key == ENCRYPTED_EC_PRIVATE_KEY + assert passphrase == PASSPHRASE_VALUE + + @mock.patch("subprocess.Popen", autospec=True) + def test_success_with_cert_chain(self, mock_popen): + PUBLIC_CERT_CHAIN_BYTES = pytest.public_cert_bytes + pytest.public_cert_bytes + mock_popen.return_value = self.create_mock_process( + PUBLIC_CERT_CHAIN_BYTES + pytest.private_key_bytes, b"" + ) + cert, key, passphrase = _mtls_helper._run_cert_provider_command(["command"]) + assert cert == PUBLIC_CERT_CHAIN_BYTES + assert key == pytest.private_key_bytes + assert passphrase is None + + mock_popen.return_value = self.create_mock_process( + PUBLIC_CERT_CHAIN_BYTES + ENCRYPTED_EC_PRIVATE_KEY + PASSPHRASE, b"" + ) + cert, key, passphrase = _mtls_helper._run_cert_provider_command( + ["command"], expect_encrypted_key=True + ) + assert cert == PUBLIC_CERT_CHAIN_BYTES + assert key == ENCRYPTED_EC_PRIVATE_KEY + assert passphrase == PASSPHRASE_VALUE + + @mock.patch("subprocess.Popen", autospec=True) + def test_missing_cert(self, mock_popen): + mock_popen.return_value = self.create_mock_process( + pytest.private_key_bytes, b"" + ) + with pytest.raises(exceptions.ClientCertError): + _mtls_helper._run_cert_provider_command(["command"]) + + mock_popen.return_value = self.create_mock_process( + ENCRYPTED_EC_PRIVATE_KEY + PASSPHRASE, b"" + ) + with pytest.raises(exceptions.ClientCertError): + _mtls_helper._run_cert_provider_command( + ["command"], expect_encrypted_key=True + ) + + @mock.patch("subprocess.Popen", autospec=True) + def test_missing_key(self, mock_popen): + mock_popen.return_value = self.create_mock_process( + pytest.public_cert_bytes, b"" + ) + with pytest.raises(exceptions.ClientCertError): + _mtls_helper._run_cert_provider_command(["command"]) + + mock_popen.return_value = self.create_mock_process( + pytest.public_cert_bytes + PASSPHRASE, b"" + ) + with pytest.raises(exceptions.ClientCertError): + _mtls_helper._run_cert_provider_command( + ["command"], expect_encrypted_key=True + ) + + @mock.patch("subprocess.Popen", autospec=True) + def test_missing_passphrase(self, mock_popen): + mock_popen.return_value = self.create_mock_process( + pytest.public_cert_bytes + ENCRYPTED_EC_PRIVATE_KEY, b"" + ) + with pytest.raises(exceptions.ClientCertError): + _mtls_helper._run_cert_provider_command( + ["command"], expect_encrypted_key=True + ) + + @mock.patch("subprocess.Popen", autospec=True) + def test_passphrase_not_expected(self, mock_popen): + mock_popen.return_value = self.create_mock_process( + pytest.public_cert_bytes + pytest.private_key_bytes + PASSPHRASE, b"" + ) + with pytest.raises(exceptions.ClientCertError): + _mtls_helper._run_cert_provider_command(["command"]) + + @mock.patch("subprocess.Popen", autospec=True) + def test_encrypted_key_expected(self, mock_popen): + mock_popen.return_value = self.create_mock_process( + pytest.public_cert_bytes + pytest.private_key_bytes + PASSPHRASE, b"" + ) + with pytest.raises(exceptions.ClientCertError): + _mtls_helper._run_cert_provider_command( + ["command"], expect_encrypted_key=True + ) + + @mock.patch("subprocess.Popen", autospec=True) + def test_unencrypted_key_expected(self, mock_popen): + mock_popen.return_value = self.create_mock_process( + pytest.public_cert_bytes + ENCRYPTED_EC_PRIVATE_KEY, b"" + ) + with pytest.raises(exceptions.ClientCertError): + _mtls_helper._run_cert_provider_command(["command"]) + + @mock.patch("subprocess.Popen", autospec=True) + def test_cert_provider_returns_error(self, mock_popen): + mock_popen.return_value = self.create_mock_process(b"", b"some error") + mock_popen.return_value.returncode = 1 + with pytest.raises(exceptions.ClientCertError): + _mtls_helper._run_cert_provider_command(["command"]) + + @mock.patch("subprocess.Popen", autospec=True) + def test_popen_raise_exception(self, mock_popen): + mock_popen.side_effect = OSError() + with pytest.raises(exceptions.ClientCertError): + _mtls_helper._run_cert_provider_command(["command"]) + + +class TestGetClientSslCredentials(object): + @mock.patch( + "google.auth.transport._mtls_helper._get_workload_cert_and_key", autospec=True + ) + @mock.patch( + "google.auth.transport._mtls_helper._run_cert_provider_command", autospec=True + ) + @mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True) + @mock.patch("google.auth.transport._mtls_helper._check_config_path", autospec=True) + def test_success_with_context_aware_metadata( + self, + mock_check_config_path, + mock_load_json_file, + mock_run_cert_provider_command, + mock_get_workload_cert_and_key, + ): + mock_check_config_path.return_value = "/path/to/config" + mock_load_json_file.return_value = {"cert_provider_command": ["command"]} + mock_run_cert_provider_command.return_value = (b"cert", b"key", None) + mock_get_workload_cert_and_key.return_value = (None, None) + has_cert, cert, key, passphrase = _mtls_helper.get_client_ssl_credentials() + assert has_cert + assert cert == b"cert" + assert key == b"key" + assert passphrase is None + + @mock.patch( + "google.auth.transport._mtls_helper._read_cert_and_key_files", autospec=True + ) + @mock.patch( + "google.auth.transport._mtls_helper._get_cert_config_path", autospec=True + ) + @mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True) + @mock.patch("google.auth.transport._mtls_helper._check_config_path", autospec=True) + def test_success_with_certificate_config( + self, + mock_check_config_path, + mock_load_json_file, + mock_get_cert_config_path, + mock_read_cert_and_key_files, + ): + cert_config_path = "/path/to/config" + mock_check_config_path.return_value = cert_config_path + mock_load_json_file.return_value = { + "cert_configs": { + "workload": {"cert_path": "cert/path", "key_path": "key/path"} + } + } + mock_get_cert_config_path.return_value = cert_config_path + mock_read_cert_and_key_files.return_value = ( + pytest.public_cert_bytes, + pytest.private_key_bytes, + ) + + has_cert, cert, key, passphrase = _mtls_helper.get_client_ssl_credentials() + assert has_cert + assert cert == pytest.public_cert_bytes + assert key == pytest.private_key_bytes + assert passphrase is None + + @mock.patch("google.auth.transport._mtls_helper._check_config_path", autospec=True) + def test_success_without_metadata(self, mock_check_config_path): + mock_check_config_path.return_value = False + has_cert, cert, key, passphrase = _mtls_helper.get_client_ssl_credentials() + assert not has_cert + assert cert is None + assert key is None + assert passphrase is None + + @mock.patch( + "google.auth.transport._mtls_helper._get_workload_cert_and_key", autospec=True + ) + @mock.patch( + "google.auth.transport._mtls_helper._run_cert_provider_command", autospec=True + ) + @mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True) + @mock.patch("google.auth.transport._mtls_helper._check_config_path", autospec=True) + def test_success_with_encrypted_key( + self, + mock_check_config_path, + mock_load_json_file, + mock_run_cert_provider_command, + mock_get_workload_cert_and_key, + ): + mock_check_config_path.return_value = "/path/to/config" + mock_load_json_file.return_value = {"cert_provider_command": ["command"]} + mock_run_cert_provider_command.return_value = (b"cert", b"key", b"passphrase") + mock_get_workload_cert_and_key.return_value = (None, None) + has_cert, cert, key, passphrase = _mtls_helper.get_client_ssl_credentials( + generate_encrypted_key=True + ) + assert has_cert + assert cert == b"cert" + assert key == b"key" + assert passphrase == b"passphrase" + mock_run_cert_provider_command.assert_called_once_with( + ["command", "--with_passphrase"], expect_encrypted_key=True + ) + + @mock.patch( + "google.auth.transport._mtls_helper._get_workload_cert_and_key", autospec=True + ) + @mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True) + @mock.patch("google.auth.transport._mtls_helper._check_config_path", autospec=True) + def test_missing_cert_command( + self, + mock_check_config_path, + mock_load_json_file, + mock_get_workload_cert_and_key, + ): + mock_check_config_path.return_value = "/path/to/config" + mock_load_json_file.return_value = {} + mock_get_workload_cert_and_key.return_value = (None, None) + with pytest.raises(exceptions.ClientCertError): + _mtls_helper.get_client_ssl_credentials() + + @mock.patch( + "google.auth.transport._mtls_helper._run_cert_provider_command", autospec=True + ) + @mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True) + @mock.patch("google.auth.transport._mtls_helper._check_config_path", autospec=True) + def test_customize_context_aware_metadata_path( + self, + mock_check_config_path, + mock_load_json_file, + mock_run_cert_provider_command, + ): + context_aware_metadata_path = "/path/to/metata/data" + mock_check_config_path.return_value = context_aware_metadata_path + mock_load_json_file.return_value = {"cert_provider_command": ["command"]} + mock_run_cert_provider_command.return_value = (b"cert", b"key", None) + + has_cert, cert, key, passphrase = _mtls_helper.get_client_ssl_credentials( + context_aware_metadata_path=context_aware_metadata_path + ) + + assert has_cert + assert cert == b"cert" + assert key == b"key" + assert passphrase is None + mock_check_config_path.assert_called_with(context_aware_metadata_path) + mock_load_json_file.assert_called_with(context_aware_metadata_path) + + +class TestGetWorkloadCertAndKey(object): + @mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True) + @mock.patch( + "google.auth.transport._mtls_helper._get_cert_config_path", autospec=True + ) + @mock.patch( + "google.auth.transport._mtls_helper._read_cert_and_key_files", autospec=True + ) + def test_success( + self, + mock_read_cert_and_key_files, + mock_get_cert_config_path, + mock_load_json_file, + ): + cert_config_path = "/path/to/cert" + mock_get_cert_config_path.return_value = "/path/to/cert" + mock_load_json_file.return_value = { + "cert_configs": { + "workload": {"cert_path": "cert/path", "key_path": "key/path"} + } + } + mock_read_cert_and_key_files.return_value = ( + pytest.public_cert_bytes, + pytest.private_key_bytes, + ) + + actual_cert, actual_key = _mtls_helper._get_workload_cert_and_key( + cert_config_path + ) + assert actual_cert == pytest.public_cert_bytes + assert actual_key == pytest.private_key_bytes + + @mock.patch( + "google.auth.transport._mtls_helper._get_cert_config_path", autospec=True + ) + def test_file_not_found_returns_none(self, mock_get_cert_config_path): + mock_get_cert_config_path.return_value = None + + actual_cert, actual_key = _mtls_helper._get_workload_cert_and_key() + assert actual_cert is None + assert actual_key is None + + @mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True) + @mock.patch( + "google.auth.transport._mtls_helper._get_cert_config_path", autospec=True + ) + def test_no_cert_configs(self, mock_get_cert_config_path, mock_load_json_file): + mock_get_cert_config_path.return_value = "/path/to/cert" + mock_load_json_file.return_value = {} + + with pytest.raises(exceptions.ClientCertError): + _mtls_helper._get_workload_cert_and_key("") + + @mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True) + @mock.patch( + "google.auth.transport._mtls_helper._get_cert_config_path", autospec=True + ) + def test_no_workload(self, mock_get_cert_config_path, mock_load_json_file): + mock_get_cert_config_path.return_value = "/path/to/cert" + mock_load_json_file.return_value = {"cert_configs": {}} + + with pytest.raises(exceptions.ClientCertError): + _mtls_helper._get_workload_cert_and_key("") + + @mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True) + @mock.patch( + "google.auth.transport._mtls_helper._get_cert_config_path", autospec=True + ) + def test_no_cert_file(self, mock_get_cert_config_path, mock_load_json_file): + mock_get_cert_config_path.return_value = "/path/to/cert" + mock_load_json_file.return_value = { + "cert_configs": {"workload": {"key_path": "path/to/key"}} + } + + with pytest.raises(exceptions.ClientCertError): + _mtls_helper._get_workload_cert_and_key("") + + @mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True) + @mock.patch( + "google.auth.transport._mtls_helper._get_cert_config_path", autospec=True + ) + def test_no_key_file(self, mock_get_cert_config_path, mock_load_json_file): + mock_get_cert_config_path.return_value = "/path/to/cert" + mock_load_json_file.return_value = { + "cert_configs": {"workload": {"cert_path": "path/to/key"}} + } + + with pytest.raises(exceptions.ClientCertError): + _mtls_helper._get_workload_cert_and_key("") + + +class TestReadCertAndKeyFile(object): + def test_success(self): + cert_path = os.path.join(pytest.data_dir, "public_cert.pem") + key_path = os.path.join(pytest.data_dir, "privatekey.pem") + + actual_cert, actual_key = _mtls_helper._read_cert_and_key_files( + cert_path, key_path + ) + assert actual_cert == pytest.public_cert_bytes + assert actual_key == pytest.private_key_bytes + + def test_no_cert_file(self): + cert_path = "fake/file/path" + key_path = os.path.join(pytest.data_dir, "privatekey.pem") + with pytest.raises(FileNotFoundError): + _mtls_helper._read_cert_and_key_files(cert_path, key_path) + + def test_no_key_file(self): + cert_path = os.path.join(pytest.data_dir, "public_cert.pem") + key_path = "fake/file/path" + with pytest.raises(FileNotFoundError): + _mtls_helper._read_cert_and_key_files(cert_path, key_path) + + def test_invalid_cert_file(self): + cert_path = os.path.join(pytest.data_dir, "service_account.json") + key_path = os.path.join(pytest.data_dir, "privatekey.pem") + with pytest.raises(exceptions.ClientCertError): + _mtls_helper._read_cert_and_key_files(cert_path, key_path) + + def test_invalid_key_file(self): + cert_path = os.path.join(pytest.data_dir, "public_cert.pem") + key_path = os.path.join(pytest.data_dir, "public_cert.pem") + with pytest.raises(exceptions.ClientCertError): + _mtls_helper._read_cert_and_key_files(cert_path, key_path) + + +class TestGetCertConfigPath(object): + def test_success_with_override(self): + config_path = os.path.join(pytest.data_dir, "service_account.json") + returned_path = _mtls_helper._get_cert_config_path(config_path) + assert returned_path == config_path + + def test_override_does_not_exist(self): + config_path = "fake/file/path" + returned_path = _mtls_helper._get_cert_config_path(config_path) + assert returned_path is None + + @mock.patch.dict(os.environ, {"GOOGLE_API_CERTIFICATE_CONFIG": ""}) + @mock.patch("os.path.exists", autospec=True) + def test_default(self, mock_path_exists): + mock_path_exists.return_value = True + returned_path = _mtls_helper._get_cert_config_path() + expected_path = os.path.expanduser( + _mtls_helper.CERTIFICATE_CONFIGURATION_DEFAULT_PATH + ) + assert returned_path == expected_path + + @mock.patch.dict( + os.environ, {"GOOGLE_API_CERTIFICATE_CONFIG": "path/to/config/file"} + ) + @mock.patch("os.path.exists", autospec=True) + def test_env_variable(self, mock_path_exists): + mock_path_exists.return_value = True + returned_path = _mtls_helper._get_cert_config_path() + expected_path = "path/to/config/file" + assert returned_path == expected_path + + @mock.patch.dict(os.environ, {"GOOGLE_API_CERTIFICATE_CONFIG": ""}) + @mock.patch("os.path.exists", autospec=True) + def test_env_variable_file_does_not_exist(self, mock_path_exists): + mock_path_exists.return_value = False + returned_path = _mtls_helper._get_cert_config_path() + assert returned_path is None + + @mock.patch.dict( + os.environ, {"GOOGLE_API_CERTIFICATE_CONFIG": "path/to/config/file"} + ) + @mock.patch("os.path.exists", autospec=True) + def test_default_file_does_not_exist(self, mock_path_exists): + mock_path_exists.return_value = False + returned_path = _mtls_helper._get_cert_config_path() + assert returned_path is None + + +class TestGetClientCertAndKey(object): + def test_callback_success(self): + callback = mock.Mock() + callback.return_value = (pytest.public_cert_bytes, pytest.private_key_bytes) + + found_cert_key, cert, key = _mtls_helper.get_client_cert_and_key(callback) + assert found_cert_key + assert cert == pytest.public_cert_bytes + assert key == pytest.private_key_bytes + + @mock.patch( + "google.auth.transport._mtls_helper.get_client_ssl_credentials", autospec=True + ) + def test_use_metadata(self, mock_get_client_ssl_credentials): + mock_get_client_ssl_credentials.return_value = ( + True, + pytest.public_cert_bytes, + pytest.private_key_bytes, + None, + ) + + found_cert_key, cert, key = _mtls_helper.get_client_cert_and_key() + assert found_cert_key + assert cert == pytest.public_cert_bytes + assert key == pytest.private_key_bytes + + +class TestDecryptPrivateKey(object): + def test_success(self): + decrypted_key = _mtls_helper.decrypt_private_key( + ENCRYPTED_EC_PRIVATE_KEY, PASSPHRASE_VALUE + ) + private_key = crypto.load_privatekey(crypto.FILETYPE_PEM, decrypted_key) + public_key = crypto.load_publickey(crypto.FILETYPE_PEM, EC_PUBLIC_KEY) + x509 = crypto.X509() + x509.set_pubkey(public_key) + + # Test the decrypted key works by signing and verification. + signature = crypto.sign(private_key, b"data", "sha256") + crypto.verify(x509, signature, b"data", "sha256") + + def test_crypto_error(self): + with pytest.raises(crypto.Error): + _mtls_helper.decrypt_private_key( + ENCRYPTED_EC_PRIVATE_KEY, b"wrong_password" + ) + + def test_check_use_client_cert(self, monkeypatch): + monkeypatch.setenv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "true") + use_client_cert = _mtls_helper.check_use_client_cert() + assert use_client_cert is True + + def test_check_use_client_cert_for_workload_with_config_file(self, monkeypatch): + config_data = { + "version": 1, + "cert_configs": { + "workload": { + "cert_path": "path/to/cert/file", + "key_path": "path/to/key/file", + } + }, + } + config_filename = "mock_certificate_config.json" + config_file_content = json.dumps(config_data) + monkeypatch.setenv("GOOGLE_API_CERTIFICATE_CONFIG", config_filename) + monkeypatch.setenv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "") + # Use mock_open to simulate the file in memory + mock_file_handle = mock.mock_open(read_data=config_file_content) + with mock.patch("builtins.open", mock_file_handle): + use_client_cert = _mtls_helper.check_use_client_cert() + assert use_client_cert is True + + def test_check_use_client_cert_false(self, monkeypatch): + monkeypatch.setenv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "false") + use_client_cert = _mtls_helper.check_use_client_cert() + assert use_client_cert is False + + def test_check_use_client_cert_unsupported_value(self, monkeypatch): + monkeypatch.setenv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "dummy") + use_client_cert = _mtls_helper.check_use_client_cert() + assert use_client_cert is False + + def test_check_use_client_cert_for_workload_with_config_file_not_found( + self, monkeypatch + ): + monkeypatch.setenv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "") + use_client_cert = _mtls_helper.check_use_client_cert() + assert use_client_cert is False + + def test_check_use_client_cert_for_workload_with_config_file_not_json( + self, monkeypatch + ): + config_filename = "mock_certificate_config.json" + config_file_content = "not_valid_json" + monkeypatch.setenv("GOOGLE_API_CERTIFICATE_CONFIG", config_filename) + monkeypatch.setenv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "") + # Use mock_open to simulate the file in memory + mock_file_handle = mock.mock_open(read_data=config_file_content) + with mock.patch("builtins.open", mock_file_handle): + use_client_cert = _mtls_helper.check_use_client_cert() + assert use_client_cert is False + + def test_check_use_client_cert_for_workload_with_config_file_no_workload( + self, monkeypatch + ): + config_data = {"version": 1, "cert_configs": {"dummy_key": {}}} + config_filename = "mock_certificate_config.json" + config_file_content = json.dumps(config_data) + monkeypatch.setenv("GOOGLE_API_CERTIFICATE_CONFIG", config_filename) + monkeypatch.setenv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "") + # Use mock_open to simulate the file in memory + mock_file_handle = mock.mock_open(read_data=config_file_content) + with mock.patch("builtins.open", mock_file_handle): + use_client_cert = _mtls_helper.check_use_client_cert() + assert use_client_cert is False + + def test_check_use_client_cert_when_file_does_not_exist(self, monkeypatch): + config_filename = "mock_certificate_config.json" + monkeypatch.setenv("GOOGLE_API_CERTIFICATE_CONFIG", config_filename) + monkeypatch.setenv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "") + use_client_cert = _mtls_helper.check_use_client_cert() + assert use_client_cert is False diff --git a/packages/google-auth/tests/transport/test_grpc.py b/packages/google-auth/tests/transport/test_grpc.py new file mode 100644 index 000000000000..ed3f3ee83911 --- /dev/null +++ b/packages/google-auth/tests/transport/test_grpc.py @@ -0,0 +1,486 @@ +# Copyright 2016 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 datetime +import os +import time + +import mock +import pytest # type: ignore + +from google.auth import _helpers +from google.auth import credentials +from google.auth import environment_vars +from google.auth import exceptions +from google.auth import transport +from google.oauth2 import service_account + +try: + # pylint: disable=ungrouped-imports + import grpc # type: ignore + import google.auth.transport.grpc + + HAS_GRPC = True +except ImportError: # pragma: NO COVER + HAS_GRPC = False + +DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "data") +METADATA_PATH = os.path.join(DATA_DIR, "context_aware_metadata.json") +with open(os.path.join(DATA_DIR, "privatekey.pem"), "rb") as fh: + PRIVATE_KEY_BYTES = fh.read() +with open(os.path.join(DATA_DIR, "public_cert.pem"), "rb") as fh: + PUBLIC_CERT_BYTES = fh.read() + +pytestmark = pytest.mark.skipif(not HAS_GRPC, reason="gRPC is unavailable.") + + +class CredentialsStub(credentials.Credentials): + def __init__(self, token="token"): + super(CredentialsStub, self).__init__() + self.token = token + self.expiry = None + + def refresh(self, request): + self.token += "1" + + def with_quota_project(self, quota_project_id): + raise NotImplementedError() + + +class TestAuthMetadataPlugin(object): + def test_call_no_refresh(self): + credentials = CredentialsStub() + request = mock.create_autospec(transport.Request) + + plugin = google.auth.transport.grpc.AuthMetadataPlugin(credentials, request) + + context = mock.create_autospec(grpc.AuthMetadataContext, instance=True) + context.method_name = mock.sentinel.method_name + context.service_url = mock.sentinel.service_url + callback = mock.create_autospec(grpc.AuthMetadataPluginCallback) + + plugin(context, callback) + + time.sleep(2) + + callback.assert_called_once_with( + [("authorization", "Bearer {}".format(credentials.token))], None + ) + + def test_call_refresh(self): + credentials = CredentialsStub() + credentials.expiry = datetime.datetime.min + _helpers.REFRESH_THRESHOLD + request = mock.create_autospec(transport.Request) + + plugin = google.auth.transport.grpc.AuthMetadataPlugin(credentials, request) + + context = mock.create_autospec(grpc.AuthMetadataContext, instance=True) + context.method_name = mock.sentinel.method_name + context.service_url = mock.sentinel.service_url + callback = mock.create_autospec(grpc.AuthMetadataPluginCallback) + + plugin(context, callback) + + time.sleep(2) + + assert credentials.token == "token1" + callback.assert_called_once_with( + [("authorization", "Bearer {}".format(credentials.token))], None + ) + + def test__get_authorization_headers_with_service_account(self): + credentials = mock.create_autospec(service_account.Credentials) + request = mock.create_autospec(transport.Request) + + plugin = google.auth.transport.grpc.AuthMetadataPlugin(credentials, request) + + context = mock.create_autospec(grpc.AuthMetadataContext, instance=True) + context.method_name = "methodName" + context.service_url = "https://pubsub.googleapis.com/methodName" + + plugin._get_authorization_headers(context) + + credentials._create_self_signed_jwt.assert_called_once_with(None) + + def test__get_authorization_headers_with_service_account_and_default_host(self): + credentials = mock.create_autospec(service_account.Credentials) + request = mock.create_autospec(transport.Request) + + default_host = "pubsub.googleapis.com" + plugin = google.auth.transport.grpc.AuthMetadataPlugin( + credentials, request, default_host=default_host + ) + + context = mock.create_autospec(grpc.AuthMetadataContext, instance=True) + context.method_name = "methodName" + context.service_url = "https://pubsub.googleapis.com/methodName" + + plugin._get_authorization_headers(context) + + credentials._create_self_signed_jwt.assert_called_once_with( + "https://{}/".format(default_host) + ) + + +@mock.patch( + "google.auth.transport._mtls_helper.get_client_ssl_credentials", autospec=True +) +@mock.patch("grpc.composite_channel_credentials", autospec=True) +@mock.patch("grpc.metadata_call_credentials", autospec=True) +@mock.patch("grpc.ssl_channel_credentials", autospec=True) +@mock.patch("grpc.secure_channel", autospec=True) +class TestSecureAuthorizedChannel(object): + @mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True) + @mock.patch("google.auth.transport._mtls_helper._check_config_path", autospec=True) + def test_secure_authorized_channel_adc( + self, + check_config_path, + load_json_file, + secure_channel, + ssl_channel_credentials, + metadata_call_credentials, + composite_channel_credentials, + get_client_ssl_credentials, + ): + credentials = CredentialsStub() + request = mock.create_autospec(transport.Request) + target = "example.com:80" + + # Mock the context aware metadata and client cert/key so mTLS SSL channel + # will be used. + check_config_path.return_value = METADATA_PATH + load_json_file.return_value = {"cert_provider_command": ["some command"]} + get_client_ssl_credentials.return_value = ( + True, + PUBLIC_CERT_BYTES, + PRIVATE_KEY_BYTES, + None, + ) + + channel = None + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + channel = google.auth.transport.grpc.secure_authorized_channel( + credentials, request, target, options=mock.sentinel.options + ) + + # Check the auth plugin construction. + auth_plugin = metadata_call_credentials.call_args[0][0] + assert isinstance(auth_plugin, google.auth.transport.grpc.AuthMetadataPlugin) + assert auth_plugin._credentials == credentials + assert auth_plugin._request == request + + # Check the ssl channel call. + ssl_channel_credentials.assert_called_once_with( + certificate_chain=PUBLIC_CERT_BYTES, private_key=PRIVATE_KEY_BYTES + ) + + # Check the composite credentials call. + composite_channel_credentials.assert_called_once_with( + ssl_channel_credentials.return_value, metadata_call_credentials.return_value + ) + + # Check the channel call. + secure_channel.assert_called_once_with( + target, + composite_channel_credentials.return_value, + options=mock.sentinel.options, + ) + assert channel == secure_channel.return_value + + @mock.patch("google.auth.transport.grpc.SslCredentials", autospec=True) + def test_secure_authorized_channel_adc_without_client_cert_env( + self, + ssl_credentials_adc_method, + secure_channel, + ssl_channel_credentials, + metadata_call_credentials, + composite_channel_credentials, + get_client_ssl_credentials, + ): + # Test client cert won't be used if GOOGLE_API_USE_CLIENT_CERTIFICATE + # environment variable is not set. + credentials = CredentialsStub() + request = mock.create_autospec(transport.Request) + target = "example.com:80" + + channel = google.auth.transport.grpc.secure_authorized_channel( + credentials, request, target, options=mock.sentinel.options + ) + + # Check the auth plugin construction. + auth_plugin = metadata_call_credentials.call_args[0][0] + assert isinstance(auth_plugin, google.auth.transport.grpc.AuthMetadataPlugin) + assert auth_plugin._credentials == credentials + assert auth_plugin._request == request + + # Check the ssl channel call. + ssl_channel_credentials.assert_called_once() + ssl_credentials_adc_method.assert_not_called() + + # Check the composite credentials call. + composite_channel_credentials.assert_called_once_with( + ssl_channel_credentials.return_value, metadata_call_credentials.return_value + ) + + # Check the channel call. + secure_channel.assert_called_once_with( + target, + composite_channel_credentials.return_value, + options=mock.sentinel.options, + ) + assert channel == secure_channel.return_value + + def test_secure_authorized_channel_explicit_ssl( + self, + secure_channel, + ssl_channel_credentials, + metadata_call_credentials, + composite_channel_credentials, + get_client_ssl_credentials, + ): + credentials = mock.Mock() + request = mock.Mock() + target = "example.com:80" + ssl_credentials = mock.Mock() + + google.auth.transport.grpc.secure_authorized_channel( + credentials, request, target, ssl_credentials=ssl_credentials + ) + + # Since explicit SSL credentials are provided, get_client_ssl_credentials + # shouldn't be called. + assert not get_client_ssl_credentials.called + + # Check the ssl channel call. + assert not ssl_channel_credentials.called + + # Check the composite credentials call. + composite_channel_credentials.assert_called_once_with( + ssl_credentials, metadata_call_credentials.return_value + ) + + def test_secure_authorized_channel_mutual_exclusive( + self, + secure_channel, + ssl_channel_credentials, + metadata_call_credentials, + composite_channel_credentials, + get_client_ssl_credentials, + ): + credentials = mock.Mock() + request = mock.Mock() + target = "example.com:80" + ssl_credentials = mock.Mock() + client_cert_callback = mock.Mock() + + with pytest.raises(ValueError): + google.auth.transport.grpc.secure_authorized_channel( + credentials, + request, + target, + ssl_credentials=ssl_credentials, + client_cert_callback=client_cert_callback, + ) + + def test_secure_authorized_channel_with_client_cert_callback_success( + self, + secure_channel, + ssl_channel_credentials, + metadata_call_credentials, + composite_channel_credentials, + get_client_ssl_credentials, + ): + credentials = mock.Mock() + request = mock.Mock() + target = "example.com:80" + client_cert_callback = mock.Mock() + client_cert_callback.return_value = (PUBLIC_CERT_BYTES, PRIVATE_KEY_BYTES) + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + google.auth.transport.grpc.secure_authorized_channel( + credentials, request, target, client_cert_callback=client_cert_callback + ) + + client_cert_callback.assert_called_once() + + # Check we are using the cert and key provided by client_cert_callback. + ssl_channel_credentials.assert_called_once_with( + certificate_chain=PUBLIC_CERT_BYTES, private_key=PRIVATE_KEY_BYTES + ) + + # Check the composite credentials call. + composite_channel_credentials.assert_called_once_with( + ssl_channel_credentials.return_value, metadata_call_credentials.return_value + ) + + @mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True) + @mock.patch("google.auth.transport._mtls_helper._check_config_path", autospec=True) + def test_secure_authorized_channel_with_client_cert_callback_failure( + self, + check_config_path, + load_json_file, + secure_channel, + ssl_channel_credentials, + metadata_call_credentials, + composite_channel_credentials, + get_client_ssl_credentials, + ): + credentials = mock.Mock() + request = mock.Mock() + target = "example.com:80" + + client_cert_callback = mock.Mock() + client_cert_callback.side_effect = Exception("callback exception") + + with pytest.raises(Exception) as excinfo: + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + google.auth.transport.grpc.secure_authorized_channel( + credentials, + request, + target, + client_cert_callback=client_cert_callback, + ) + + assert str(excinfo.value) == "callback exception" + + def test_secure_authorized_channel_cert_callback_without_client_cert_env( + self, + secure_channel, + ssl_channel_credentials, + metadata_call_credentials, + composite_channel_credentials, + get_client_ssl_credentials, + ): + # Test client cert won't be used if GOOGLE_API_USE_CLIENT_CERTIFICATE + # environment variable is not set. + credentials = mock.Mock() + request = mock.Mock() + target = "example.com:80" + client_cert_callback = mock.Mock() + + google.auth.transport.grpc.secure_authorized_channel( + credentials, request, target, client_cert_callback=client_cert_callback + ) + + # Check client_cert_callback is not called because GOOGLE_API_USE_CLIENT_CERTIFICATE + # is not set. + client_cert_callback.assert_not_called() + + ssl_channel_credentials.assert_called_once() + + # Check the composite credentials call. + composite_channel_credentials.assert_called_once_with( + ssl_channel_credentials.return_value, metadata_call_credentials.return_value + ) + + +@mock.patch("grpc.ssl_channel_credentials", autospec=True) +@mock.patch( + "google.auth.transport._mtls_helper.get_client_ssl_credentials", autospec=True +) +@mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True) +@mock.patch("google.auth.transport._mtls_helper._check_config_path", autospec=True) +class TestSslCredentials(object): + def test_no_context_aware_metadata( + self, + mock_check_config_path, + mock_load_json_file, + mock_get_client_ssl_credentials, + mock_ssl_channel_credentials, + ): + # Mock that the metadata file doesn't exist. + mock_check_config_path.return_value = None + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + ssl_credentials = google.auth.transport.grpc.SslCredentials() + + # Since no context aware metadata is found, we wouldn't call + # get_client_ssl_credentials, and the SSL channel credentials created is + # non mTLS. + assert ssl_credentials.ssl_credentials is not None + assert not ssl_credentials.is_mtls + mock_get_client_ssl_credentials.assert_not_called() + mock_ssl_channel_credentials.assert_called_once_with() + + def test_get_client_ssl_credentials_failure( + self, + mock_check_config_path, + mock_load_json_file, + mock_get_client_ssl_credentials, + mock_ssl_channel_credentials, + ): + mock_check_config_path.return_value = METADATA_PATH + mock_load_json_file.return_value = {"cert_provider_command": ["some command"]} + + # Mock that client cert and key are not loaded and exception is raised. + mock_get_client_ssl_credentials.side_effect = exceptions.ClientCertError() + + with pytest.raises(exceptions.MutualTLSChannelError): + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + assert google.auth.transport.grpc.SslCredentials().ssl_credentials + + def test_get_client_ssl_credentials_success( + self, + mock_check_config_path, + mock_load_json_file, + mock_get_client_ssl_credentials, + mock_ssl_channel_credentials, + ): + mock_check_config_path.return_value = METADATA_PATH + mock_load_json_file.return_value = {"cert_provider_command": ["some command"]} + mock_get_client_ssl_credentials.return_value = ( + True, + PUBLIC_CERT_BYTES, + PRIVATE_KEY_BYTES, + None, + ) + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + ssl_credentials = google.auth.transport.grpc.SslCredentials() + + assert ssl_credentials.ssl_credentials is not None + assert ssl_credentials.is_mtls + mock_get_client_ssl_credentials.assert_called_once() + mock_ssl_channel_credentials.assert_called_once_with( + certificate_chain=PUBLIC_CERT_BYTES, private_key=PRIVATE_KEY_BYTES + ) + + def test_get_client_ssl_credentials_without_client_cert_env( + self, + mock_check_config_path, + mock_load_json_file, + mock_get_client_ssl_credentials, + mock_ssl_channel_credentials, + ): + # Test client cert won't be used if GOOGLE_API_USE_CLIENT_CERTIFICATE is not set. + ssl_credentials = google.auth.transport.grpc.SslCredentials() + + assert ssl_credentials.ssl_credentials is not None + assert not ssl_credentials.is_mtls + mock_check_config_path.assert_not_called() + mock_load_json_file.assert_not_called() + mock_get_client_ssl_credentials.assert_not_called() + mock_ssl_channel_credentials.assert_called_once() diff --git a/packages/google-auth/tests/transport/test_mtls.py b/packages/google-auth/tests/transport/test_mtls.py new file mode 100644 index 000000000000..ef305395827c --- /dev/null +++ b/packages/google-auth/tests/transport/test_mtls.py @@ -0,0 +1,105 @@ +# 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 mock +import pytest # type: ignore + +from google.auth import exceptions +from google.auth.transport import _mtls_helper +from google.auth.transport import mtls + + +@mock.patch("google.auth.transport._mtls_helper._check_config_path", autospec=True) +def test_has_default_client_cert_source(check_config_path): + def return_path_for_metadata(path): + return mock.Mock() if path == _mtls_helper.CONTEXT_AWARE_METADATA_PATH else None + + check_config_path.side_effect = return_path_for_metadata + assert mtls.has_default_client_cert_source() + + def return_path_for_cert_config(path): + return ( + mock.Mock() + if path == _mtls_helper.CERTIFICATE_CONFIGURATION_DEFAULT_PATH + else None + ) + + check_config_path.side_effect = return_path_for_cert_config + assert mtls.has_default_client_cert_source() + + check_config_path.side_effect = None + check_config_path.return_value = None + assert not mtls.has_default_client_cert_source() + + +@mock.patch("google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True) +@mock.patch("google.auth.transport.mtls.has_default_client_cert_source", autospec=True) +def test_default_client_cert_source( + has_default_client_cert_source, get_client_cert_and_key +): + # Test default client cert source doesn't exist. + has_default_client_cert_source.return_value = False + with pytest.raises(exceptions.MutualTLSChannelError): + mtls.default_client_cert_source() + + # The following tests will assume default client cert source exists. + has_default_client_cert_source.return_value = True + + # Test good callback. + get_client_cert_and_key.return_value = (True, b"cert", b"key") + callback = mtls.default_client_cert_source() + assert callback() == (b"cert", b"key") + + # Test bad callback which throws exception. + get_client_cert_and_key.side_effect = ValueError() + callback = mtls.default_client_cert_source() + with pytest.raises(exceptions.MutualTLSChannelError): + callback() + + +@mock.patch( + "google.auth.transport._mtls_helper.get_client_ssl_credentials", autospec=True +) +@mock.patch("google.auth.transport.mtls.has_default_client_cert_source", autospec=True) +def test_default_client_encrypted_cert_source( + has_default_client_cert_source, get_client_ssl_credentials +): + # Test default client cert source doesn't exist. + has_default_client_cert_source.return_value = False + with pytest.raises(exceptions.MutualTLSChannelError): + mtls.default_client_encrypted_cert_source("cert_path", "key_path") + + # The following tests will assume default client cert source exists. + has_default_client_cert_source.return_value = True + + # Test good callback. + get_client_ssl_credentials.return_value = (True, b"cert", b"key", b"passphrase") + callback = mtls.default_client_encrypted_cert_source("cert_path", "key_path") + with mock.patch("{}.open".format(__name__), return_value=mock.MagicMock()): + assert callback() == ("cert_path", "key_path", b"passphrase") + + # Test bad callback which throws exception. + get_client_ssl_credentials.side_effect = exceptions.ClientCertError() + callback = mtls.default_client_encrypted_cert_source("cert_path", "key_path") + with pytest.raises(exceptions.MutualTLSChannelError): + callback() + + +@mock.patch("google.auth.transport._mtls_helper.check_use_client_cert", autospec=True) +def test_should_use_client_cert(check_use_client_cert): + check_use_client_cert.return_value = mock.Mock() + assert mtls.should_use_client_cert() + + check_use_client_cert.return_value = False + assert not mtls.should_use_client_cert() diff --git a/packages/google-auth/tests/transport/test_requests.py b/packages/google-auth/tests/transport/test_requests.py new file mode 100644 index 000000000000..0da3e36d9a30 --- /dev/null +++ b/packages/google-auth/tests/transport/test_requests.py @@ -0,0 +1,605 @@ +# Copyright 2016 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 datetime +import functools +import http.client as http_client +import os +import sys + +import freezegun +import mock +import OpenSSL +import pytest # type: ignore +import requests +import requests.adapters + +from google.auth import environment_vars +from google.auth import exceptions +import google.auth.credentials +import google.auth.transport._custom_tls_signer +import google.auth.transport._mtls_helper +import google.auth.transport.requests +from google.oauth2 import service_account +from tests.transport import compliance + + +@pytest.fixture +def frozen_time(): + with freezegun.freeze_time("1970-01-01 00:00:00", tick=False) as frozen: + yield frozen + + +class TestRequestResponse(compliance.RequestResponseTests): + def make_request(self): + return google.auth.transport.requests.Request() + + def test_timeout(self): + http = mock.create_autospec(requests.Session, instance=True) + request = google.auth.transport.requests.Request(http) + request(url="http://example.com", method="GET", timeout=5) + + assert http.request.call_args[1]["timeout"] == 5 + + def test_session_closed_on_del(self): + http = mock.create_autospec(requests.Session, instance=True) + request = google.auth.transport.requests.Request(http) + request.__del__() + http.close.assert_called_with() + + http = mock.create_autospec(requests.Session, instance=True) + http.close.side_effect = TypeError("test injected TypeError") + request = google.auth.transport.requests.Request(http) + request.__del__() + http.close.assert_called_with() + + +class TestTimeoutGuard(object): + def make_guard(self, *args, **kwargs): + return google.auth.transport.requests.TimeoutGuard(*args, **kwargs) + + def test_tracks_elapsed_time_w_numeric_timeout(self, frozen_time): + with self.make_guard(timeout=10) as guard: + frozen_time.tick(delta=datetime.timedelta(seconds=3.8)) + assert guard.remaining_timeout == 6.2 + + def test_tracks_elapsed_time_w_tuple_timeout(self, frozen_time): + with self.make_guard(timeout=(16, 19)) as guard: + frozen_time.tick(delta=datetime.timedelta(seconds=3.8)) + assert guard.remaining_timeout == (12.2, 15.2) + + def test_noop_if_no_timeout(self, frozen_time): + with self.make_guard(timeout=None) as guard: + frozen_time.tick(delta=datetime.timedelta(days=3650)) + # NOTE: no timeout error raised, despite years have passed + assert guard.remaining_timeout is None + + def test_timeout_error_w_numeric_timeout(self, frozen_time): + with pytest.raises(requests.exceptions.Timeout): + with self.make_guard(timeout=10) as guard: + frozen_time.tick(delta=datetime.timedelta(seconds=10.001)) + assert guard.remaining_timeout == pytest.approx(-0.001) + + def test_timeout_error_w_tuple_timeout(self, frozen_time): + with pytest.raises(requests.exceptions.Timeout): + with self.make_guard(timeout=(11, 10)) as guard: + frozen_time.tick(delta=datetime.timedelta(seconds=10.001)) + assert guard.remaining_timeout == pytest.approx((0.999, -0.001)) + + def test_custom_timeout_error_type(self, frozen_time): + class FooError(Exception): + pass + + with pytest.raises(FooError): + with self.make_guard(timeout=1, timeout_error_type=FooError): + frozen_time.tick(delta=datetime.timedelta(seconds=2)) + + def test_lets_suite_errors_bubble_up(self, frozen_time): + with pytest.raises(IndexError): + with self.make_guard(timeout=1): + [1, 2, 3][3] + + +class CredentialsStub(google.auth.credentials.Credentials): + def __init__(self, token="token"): + super(CredentialsStub, self).__init__() + self.token = token + + def apply(self, headers, token=None): + headers["authorization"] = self.token + + def before_request(self, request, method, url, headers): + self.apply(headers) + + def refresh(self, request): + self.token += "1" + + def with_quota_project(self, quota_project_id): + raise NotImplementedError() + + +class TimeTickCredentialsStub(CredentialsStub): + """Credentials that spend some (mocked) time when refreshing a token.""" + + def __init__(self, time_tick, token="token"): + self._time_tick = time_tick + super(TimeTickCredentialsStub, self).__init__(token=token) + + def refresh(self, request): + self._time_tick() + super(TimeTickCredentialsStub, self).refresh(requests) + + +class AdapterStub(requests.adapters.BaseAdapter): + def __init__(self, responses, headers=None): + super(AdapterStub, self).__init__() + self.responses = responses + self.requests = [] + self.headers = headers or {} + + def send(self, request, **kwargs): + # pylint: disable=arguments-differ + # request is the only required argument here and the only argument + # we care about. + self.requests.append(request) + return self.responses.pop(0) + + def close(self): # pragma: NO COVER + # pylint wants this to be here because it's abstract in the base + # class, but requests never actually calls it. + return + + +class TimeTickAdapterStub(AdapterStub): + """Adapter that spends some (mocked) time when making a request.""" + + def __init__(self, time_tick, responses, headers=None): + self._time_tick = time_tick + super(TimeTickAdapterStub, self).__init__(responses, headers=headers) + + def send(self, request, **kwargs): + self._time_tick() + return super(TimeTickAdapterStub, self).send(request, **kwargs) + + +class TestMutualTlsAdapter(object): + @mock.patch.object(requests.adapters.HTTPAdapter, "init_poolmanager") + @mock.patch.object(requests.adapters.HTTPAdapter, "proxy_manager_for") + def test_success(self, mock_proxy_manager_for, mock_init_poolmanager): + adapter = google.auth.transport.requests._MutualTlsAdapter( + pytest.public_cert_bytes, pytest.private_key_bytes + ) + + adapter.init_poolmanager() + mock_init_poolmanager.assert_called_with(ssl_context=adapter._ctx_poolmanager) + + adapter.proxy_manager_for() + mock_proxy_manager_for.assert_called_with(ssl_context=adapter._ctx_proxymanager) + + def test_invalid_cert_or_key(self): + with pytest.raises(OpenSSL.crypto.Error): + google.auth.transport.requests._MutualTlsAdapter( + b"invalid cert", b"invalid key" + ) + + @mock.patch.dict("sys.modules", {"OpenSSL.crypto": None}) + def test_import_error(self): + with pytest.raises(ImportError): + google.auth.transport.requests._MutualTlsAdapter( + pytest.public_cert_bytes, pytest.private_key_bytes + ) + + +def make_response(status=http_client.OK, data=None): + response = requests.Response() + response.status_code = status + response._content = data + return response + + +class TestAuthorizedSession(object): + TEST_URL = "http://example.com/" + + def test_constructor(self): + authed_session = google.auth.transport.requests.AuthorizedSession( + mock.sentinel.credentials + ) + + assert authed_session.credentials == mock.sentinel.credentials + + def test_constructor_with_auth_request(self): + http = mock.create_autospec(requests.Session) + auth_request = google.auth.transport.requests.Request(http) + + authed_session = google.auth.transport.requests.AuthorizedSession( + mock.sentinel.credentials, auth_request=auth_request + ) + + assert authed_session._auth_request is auth_request + + def test_request_default_timeout(self): + credentials = mock.Mock(wraps=CredentialsStub()) + response = make_response() + adapter = AdapterStub([response]) + + authed_session = google.auth.transport.requests.AuthorizedSession(credentials) + authed_session.mount(self.TEST_URL, adapter) + + patcher = mock.patch("google.auth.transport.requests.requests.Session.request") + with patcher as patched_request: + authed_session.request("GET", self.TEST_URL) + + expected_timeout = google.auth.transport.requests._DEFAULT_TIMEOUT + assert patched_request.call_args[1]["timeout"] == expected_timeout + + def test_request_no_refresh(self): + credentials = mock.Mock(wraps=CredentialsStub()) + response = make_response() + adapter = AdapterStub([response]) + + authed_session = google.auth.transport.requests.AuthorizedSession(credentials) + authed_session.mount(self.TEST_URL, adapter) + + result = authed_session.request("GET", self.TEST_URL) + + assert response == result + assert credentials.before_request.called + assert not credentials.refresh.called + assert len(adapter.requests) == 1 + assert adapter.requests[0].url == self.TEST_URL + assert adapter.requests[0].headers["authorization"] == "token" + + def test_request_refresh(self): + credentials = mock.Mock(wraps=CredentialsStub()) + final_response = make_response(status=http_client.OK) + # First request will 401, second request will succeed. + adapter = AdapterStub( + [make_response(status=http_client.UNAUTHORIZED), final_response] + ) + + authed_session = google.auth.transport.requests.AuthorizedSession( + credentials, refresh_timeout=60 + ) + authed_session.mount(self.TEST_URL, adapter) + + result = authed_session.request("GET", self.TEST_URL) + + assert result == final_response + assert credentials.before_request.call_count == 2 + assert credentials.refresh.called + assert len(adapter.requests) == 2 + + assert adapter.requests[0].url == self.TEST_URL + assert adapter.requests[0].headers["authorization"] == "token" + + assert adapter.requests[1].url == self.TEST_URL + assert adapter.requests[1].headers["authorization"] == "token1" + + def test_request_max_allowed_time_timeout_error(self, frozen_time): + tick_one_second = functools.partial( + frozen_time.tick, delta=datetime.timedelta(seconds=1.0) + ) + + credentials = mock.Mock( + wraps=TimeTickCredentialsStub(time_tick=tick_one_second) + ) + adapter = TimeTickAdapterStub( + time_tick=tick_one_second, responses=[make_response(status=http_client.OK)] + ) + + authed_session = google.auth.transport.requests.AuthorizedSession(credentials) + authed_session.mount(self.TEST_URL, adapter) + + # Because a request takes a full mocked second, max_allowed_time shorter + # than that will cause a timeout error. + with pytest.raises(requests.exceptions.Timeout): + authed_session.request("GET", self.TEST_URL, max_allowed_time=0.9) + + def test_request_max_allowed_time_w_transport_timeout_no_error(self, frozen_time): + tick_one_second = functools.partial( + frozen_time.tick, delta=datetime.timedelta(seconds=1.0) + ) + + credentials = mock.Mock( + wraps=TimeTickCredentialsStub(time_tick=tick_one_second) + ) + adapter = TimeTickAdapterStub( + time_tick=tick_one_second, + responses=[ + make_response(status=http_client.UNAUTHORIZED), + make_response(status=http_client.OK), + ], + ) + + authed_session = google.auth.transport.requests.AuthorizedSession(credentials) + authed_session.mount(self.TEST_URL, adapter) + + # A short configured transport timeout does not affect max_allowed_time. + # The latter is not adjusted to it and is only concerned with the actual + # execution time. The call below should thus not raise a timeout error. + authed_session.request("GET", self.TEST_URL, timeout=0.5, max_allowed_time=3.1) + + def test_request_max_allowed_time_w_refresh_timeout_no_error(self, frozen_time): + tick_one_second = functools.partial( + frozen_time.tick, delta=datetime.timedelta(seconds=1.0) + ) + + credentials = mock.Mock( + wraps=TimeTickCredentialsStub(time_tick=tick_one_second) + ) + adapter = TimeTickAdapterStub( + time_tick=tick_one_second, + responses=[ + make_response(status=http_client.UNAUTHORIZED), + make_response(status=http_client.OK), + ], + ) + + authed_session = google.auth.transport.requests.AuthorizedSession( + credentials, refresh_timeout=1.1 + ) + authed_session.mount(self.TEST_URL, adapter) + + # A short configured refresh timeout does not affect max_allowed_time. + # The latter is not adjusted to it and is only concerned with the actual + # execution time. The call below should thus not raise a timeout error + # (and `timeout` does not come into play either, as it's very long). + authed_session.request("GET", self.TEST_URL, timeout=60, max_allowed_time=3.1) + + def test_request_timeout_w_refresh_timeout_timeout_error(self, frozen_time): + tick_one_second = functools.partial( + frozen_time.tick, delta=datetime.timedelta(seconds=1.0) + ) + + credentials = mock.Mock( + wraps=TimeTickCredentialsStub(time_tick=tick_one_second) + ) + adapter = TimeTickAdapterStub( + time_tick=tick_one_second, + responses=[ + make_response(status=http_client.UNAUTHORIZED), + make_response(status=http_client.OK), + ], + ) + + authed_session = google.auth.transport.requests.AuthorizedSession( + credentials, refresh_timeout=100 + ) + authed_session.mount(self.TEST_URL, adapter) + + # An UNAUTHORIZED response triggers a refresh (an extra request), thus + # the final request that otherwise succeeds results in a timeout error + # (all three requests together last 3 mocked seconds). + with pytest.raises(requests.exceptions.Timeout): + authed_session.request( + "GET", self.TEST_URL, timeout=60, max_allowed_time=2.9 + ) + + def test_authorized_session_without_default_host(self): + credentials = mock.create_autospec(service_account.Credentials) + + authed_session = google.auth.transport.requests.AuthorizedSession(credentials) + + authed_session.credentials._create_self_signed_jwt.assert_called_once_with(None) + + def test_authorized_session_with_default_host(self): + default_host = "pubsub.googleapis.com" + credentials = mock.create_autospec(service_account.Credentials) + + authed_session = google.auth.transport.requests.AuthorizedSession( + credentials, default_host=default_host + ) + + authed_session.credentials._create_self_signed_jwt.assert_called_once_with( + "https://{}/".format(default_host) + ) + + def test_configure_mtls_channel_with_callback(self): + mock_callback = mock.Mock() + mock_callback.return_value = ( + pytest.public_cert_bytes, + pytest.private_key_bytes, + ) + + auth_session = google.auth.transport.requests.AuthorizedSession( + credentials=mock.Mock() + ) + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + auth_session.configure_mtls_channel(mock_callback) + + assert auth_session.is_mtls + assert isinstance( + auth_session.adapters["https://"], + google.auth.transport.requests._MutualTlsAdapter, + ) + + @mock.patch( + "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True + ) + def test_configure_mtls_channel_with_metadata(self, mock_get_client_cert_and_key): + mock_get_client_cert_and_key.return_value = ( + True, + pytest.public_cert_bytes, + pytest.private_key_bytes, + ) + + auth_session = google.auth.transport.requests.AuthorizedSession( + credentials=mock.Mock() + ) + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + auth_session.configure_mtls_channel() + + assert auth_session.is_mtls + assert isinstance( + auth_session.adapters["https://"], + google.auth.transport.requests._MutualTlsAdapter, + ) + + @mock.patch.object(google.auth.transport.requests._MutualTlsAdapter, "__init__") + @mock.patch( + "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True + ) + def test_configure_mtls_channel_non_mtls( + self, mock_get_client_cert_and_key, mock_adapter_ctor + ): + mock_get_client_cert_and_key.return_value = (False, None, None) + + auth_session = google.auth.transport.requests.AuthorizedSession( + credentials=mock.Mock() + ) + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + auth_session.configure_mtls_channel() + + assert not auth_session.is_mtls + + # Assert _MutualTlsAdapter constructor is not called. + mock_adapter_ctor.assert_not_called() + + @mock.patch( + "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True + ) + def test_configure_mtls_channel_exceptions(self, mock_get_client_cert_and_key): + mock_get_client_cert_and_key.side_effect = exceptions.ClientCertError() + + auth_session = google.auth.transport.requests.AuthorizedSession( + credentials=mock.Mock() + ) + with pytest.raises(exceptions.MutualTLSChannelError): + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + auth_session.configure_mtls_channel() + + mock_get_client_cert_and_key.return_value = (False, None, None) + with mock.patch.dict("sys.modules"): + sys.modules["OpenSSL"] = None + with pytest.raises(exceptions.MutualTLSChannelError): + with mock.patch.dict( + os.environ, + {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"}, + ): + auth_session.configure_mtls_channel() + + @mock.patch( + "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True + ) + def test_configure_mtls_channel_without_client_cert_env( + self, get_client_cert_and_key + ): + # Test client cert won't be used if GOOGLE_API_USE_CLIENT_CERTIFICATE + # environment variable is not set. + auth_session = google.auth.transport.requests.AuthorizedSession( + credentials=mock.Mock() + ) + + auth_session.configure_mtls_channel() + assert not auth_session.is_mtls + get_client_cert_and_key.assert_not_called() + + mock_callback = mock.Mock() + auth_session.configure_mtls_channel(mock_callback) + assert not auth_session.is_mtls + mock_callback.assert_not_called() + + def test_close_wo_passed_in_auth_request(self): + authed_session = google.auth.transport.requests.AuthorizedSession( + mock.sentinel.credentials + ) + authed_session._auth_request_session = mock.Mock(spec=["close"]) + + authed_session.close() + + authed_session._auth_request_session.close.assert_called_once_with() + + def test_close_w_passed_in_auth_request(self): + http = mock.create_autospec(requests.Session) + auth_request = google.auth.transport.requests.Request(http) + authed_session = google.auth.transport.requests.AuthorizedSession( + mock.sentinel.credentials, auth_request=auth_request + ) + + authed_session.close() # no raise + + +class TestMutualTlsOffloadAdapter(object): + @mock.patch.object(requests.adapters.HTTPAdapter, "init_poolmanager") + @mock.patch.object(requests.adapters.HTTPAdapter, "proxy_manager_for") + @mock.patch.object( + google.auth.transport._custom_tls_signer.CustomTlsSigner, "load_libraries" + ) + @mock.patch.object( + google.auth.transport._custom_tls_signer.CustomTlsSigner, + "attach_to_ssl_context", + ) + def test_success( + self, + mock_attach_to_ssl_context, + mock_load_libraries, + mock_proxy_manager_for, + mock_init_poolmanager, + ): + enterprise_cert_file_path = "/path/to/enterprise/cert/json" + adapter = google.auth.transport.requests._MutualTlsOffloadAdapter( + enterprise_cert_file_path + ) + + mock_load_libraries.assert_called_once() + assert mock_attach_to_ssl_context.call_count == 2 + + adapter.init_poolmanager() + mock_init_poolmanager.assert_called_with(ssl_context=adapter._ctx_poolmanager) + + adapter.proxy_manager_for() + mock_proxy_manager_for.assert_called_with(ssl_context=adapter._ctx_proxymanager) + + @mock.patch.object(requests.adapters.HTTPAdapter, "init_poolmanager") + @mock.patch.object(requests.adapters.HTTPAdapter, "proxy_manager_for") + @mock.patch.object( + google.auth.transport._custom_tls_signer.CustomTlsSigner, "should_use_provider" + ) + @mock.patch.object( + google.auth.transport._custom_tls_signer.CustomTlsSigner, "load_libraries" + ) + @mock.patch.object( + google.auth.transport._custom_tls_signer.CustomTlsSigner, + "attach_to_ssl_context", + ) + def test_success_should_use_provider( + self, + mock_attach_to_ssl_context, + mock_load_libraries, + mock_should_use_provider, + mock_proxy_manager_for, + mock_init_poolmanager, + ): + enterprise_cert_file_path = "/path/to/enterprise/cert/json" + adapter = google.auth.transport.requests._MutualTlsOffloadAdapter( + enterprise_cert_file_path + ) + + mock_should_use_provider.side_effect = True + mock_load_libraries.assert_called_once() + assert mock_attach_to_ssl_context.call_count == 2 + + adapter.init_poolmanager() + mock_init_poolmanager.assert_called_with(ssl_context=adapter._ctx_poolmanager) + + adapter.proxy_manager_for() + mock_proxy_manager_for.assert_called_with(ssl_context=adapter._ctx_proxymanager) diff --git a/packages/google-auth/tests/transport/test_urllib3.py b/packages/google-auth/tests/transport/test_urllib3.py new file mode 100644 index 000000000000..e83230032185 --- /dev/null +++ b/packages/google-auth/tests/transport/test_urllib3.py @@ -0,0 +1,322 @@ +# Copyright 2016 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 http.client as http_client +import os +import sys + +import mock +import OpenSSL +import pytest # type: ignore +import urllib3 # type: ignore + +from google.auth import environment_vars +from google.auth import exceptions +import google.auth.credentials +import google.auth.transport._mtls_helper +import google.auth.transport.urllib3 +from google.oauth2 import service_account +from tests.transport import compliance + + +class TestRequestResponse(compliance.RequestResponseTests): + def make_request(self): + http = urllib3.PoolManager() + return google.auth.transport.urllib3.Request(http) + + def test_timeout(self): + http = mock.create_autospec(urllib3.PoolManager) + request = google.auth.transport.urllib3.Request(http) + request(url="http://example.com", method="GET", timeout=5) + + assert http.request.call_args[1]["timeout"] == 5 + + +def test__make_default_http_with_certifi(): + http = google.auth.transport.urllib3._make_default_http() + assert "cert_reqs" in http.connection_pool_kw + + +@mock.patch.object(google.auth.transport.urllib3, "certifi", new=None) +def test__make_default_http_without_certifi(): + http = google.auth.transport.urllib3._make_default_http() + assert "cert_reqs" not in http.connection_pool_kw + + +class CredentialsStub(google.auth.credentials.Credentials): + def __init__(self, token="token"): + super(CredentialsStub, self).__init__() + self.token = token + + def apply(self, headers, token=None): + headers["authorization"] = self.token + + def before_request(self, request, method, url, headers): + self.apply(headers) + + def refresh(self, request): + self.token += "1" + + def with_quota_project(self, quota_project_id): + raise NotImplementedError() + + +class HttpStub(object): + def __init__(self, responses, headers=None): + self.responses = responses + self.requests = [] + self.headers = headers or {} + + def urlopen(self, method, url, body=None, headers=None, **kwargs): + self.requests.append((method, url, body, headers, kwargs)) + return self.responses.pop(0) + + def clear(self): + pass + + +class ResponseStub(object): + def __init__(self, status=http_client.OK, data=None): + self.status = status + self.data = data + + +class TestMakeMutualTlsHttp(object): + def test_success(self): + http = google.auth.transport.urllib3._make_mutual_tls_http( + pytest.public_cert_bytes, pytest.private_key_bytes + ) + assert isinstance(http, urllib3.PoolManager) + + def test_crypto_error(self): + with pytest.raises(OpenSSL.crypto.Error): + google.auth.transport.urllib3._make_mutual_tls_http( + b"invalid cert", b"invalid key" + ) + + @mock.patch.dict("sys.modules", {"OpenSSL.crypto": None}) + def test_import_error(self): + with pytest.raises(ImportError): + google.auth.transport.urllib3._make_mutual_tls_http( + pytest.public_cert_bytes, pytest.private_key_bytes + ) + + +class TestAuthorizedHttp(object): + TEST_URL = "http://example.com" + + def test_authed_http_defaults(self): + authed_http = google.auth.transport.urllib3.AuthorizedHttp( + mock.sentinel.credentials + ) + + assert authed_http.credentials == mock.sentinel.credentials + assert isinstance(authed_http.http, urllib3.PoolManager) + + def test_urlopen_no_refresh(self): + credentials = mock.Mock(wraps=CredentialsStub()) + response = ResponseStub() + http = HttpStub([response]) + + authed_http = google.auth.transport.urllib3.AuthorizedHttp( + credentials, http=http + ) + + result = authed_http.urlopen("GET", self.TEST_URL) + + assert result == response + assert credentials.before_request.called + assert not credentials.refresh.called + assert http.requests == [ + ("GET", self.TEST_URL, None, {"authorization": "token"}, {}) + ] + + def test_urlopen_refresh(self): + credentials = mock.Mock(wraps=CredentialsStub()) + final_response = ResponseStub(status=http_client.OK) + # First request will 401, second request will succeed. + http = HttpStub([ResponseStub(status=http_client.UNAUTHORIZED), final_response]) + + authed_http = google.auth.transport.urllib3.AuthorizedHttp( + credentials, http=http + ) + + authed_http = authed_http.urlopen("GET", "http://example.com") + + assert authed_http == final_response + assert credentials.before_request.call_count == 2 + assert credentials.refresh.called + assert http.requests == [ + ("GET", self.TEST_URL, None, {"authorization": "token"}, {}), + ("GET", self.TEST_URL, None, {"authorization": "token1"}, {}), + ] + + def test_urlopen_no_default_host(self): + credentials = mock.create_autospec(service_account.Credentials) + + authed_http = google.auth.transport.urllib3.AuthorizedHttp(credentials) + + authed_http.credentials._create_self_signed_jwt.assert_called_once_with(None) + + def test_urlopen_with_default_host(self): + default_host = "pubsub.googleapis.com" + credentials = mock.create_autospec(service_account.Credentials) + + authed_http = google.auth.transport.urllib3.AuthorizedHttp( + credentials, default_host=default_host + ) + + authed_http.credentials._create_self_signed_jwt.assert_called_once_with( + "https://{}/".format(default_host) + ) + + def test_proxies(self): + http = mock.create_autospec(urllib3.PoolManager) + authed_http = google.auth.transport.urllib3.AuthorizedHttp(None, http=http) + + with authed_http: + pass + + assert http.__enter__.called + assert http.__exit__.called + + authed_http.headers = mock.sentinel.headers + assert authed_http.headers == http.headers + + @mock.patch("google.auth.transport.urllib3._make_mutual_tls_http", autospec=True) + def test_configure_mtls_channel_with_callback(self, mock_make_mutual_tls_http): + callback = mock.Mock() + callback.return_value = (pytest.public_cert_bytes, pytest.private_key_bytes) + + authed_http = google.auth.transport.urllib3.AuthorizedHttp( + credentials=mock.Mock(), http=mock.Mock() + ) + + with pytest.warns(UserWarning): + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + is_mtls = authed_http.configure_mtls_channel(callback) + + assert is_mtls + mock_make_mutual_tls_http.assert_called_once_with( + cert=pytest.public_cert_bytes, key=pytest.private_key_bytes + ) + + @mock.patch("google.auth.transport.urllib3._make_mutual_tls_http", autospec=True) + @mock.patch( + "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True + ) + def test_configure_mtls_channel_with_metadata( + self, mock_get_client_cert_and_key, mock_make_mutual_tls_http + ): + authed_http = google.auth.transport.urllib3.AuthorizedHttp( + credentials=mock.Mock() + ) + + mock_get_client_cert_and_key.return_value = ( + True, + pytest.public_cert_bytes, + pytest.private_key_bytes, + ) + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + is_mtls = authed_http.configure_mtls_channel() + + assert is_mtls + mock_get_client_cert_and_key.assert_called_once() + mock_make_mutual_tls_http.assert_called_once_with( + cert=pytest.public_cert_bytes, key=pytest.private_key_bytes + ) + + @mock.patch("google.auth.transport.urllib3._make_mutual_tls_http", autospec=True) + @mock.patch( + "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True + ) + def test_configure_mtls_channel_non_mtls( + self, mock_get_client_cert_and_key, mock_make_mutual_tls_http + ): + authed_http = google.auth.transport.urllib3.AuthorizedHttp( + credentials=mock.Mock() + ) + + mock_get_client_cert_and_key.return_value = (False, None, None) + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + is_mtls = authed_http.configure_mtls_channel() + + assert not is_mtls + mock_get_client_cert_and_key.assert_called_once() + mock_make_mutual_tls_http.assert_not_called() + + @mock.patch( + "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True + ) + def test_configure_mtls_channel_exceptions(self, mock_get_client_cert_and_key): + authed_http = google.auth.transport.urllib3.AuthorizedHttp( + credentials=mock.Mock() + ) + + mock_get_client_cert_and_key.side_effect = exceptions.ClientCertError() + with pytest.raises(exceptions.MutualTLSChannelError): + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + authed_http.configure_mtls_channel() + + mock_get_client_cert_and_key.return_value = (False, None, None) + with mock.patch.dict("sys.modules"): + sys.modules["OpenSSL"] = None + with pytest.raises(exceptions.MutualTLSChannelError): + with mock.patch.dict( + os.environ, + {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"}, + ): + authed_http.configure_mtls_channel() + + @mock.patch( + "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True + ) + def test_configure_mtls_channel_without_client_cert_env( + self, get_client_cert_and_key + ): + callback = mock.Mock() + + authed_http = google.auth.transport.urllib3.AuthorizedHttp( + credentials=mock.Mock(), http=mock.Mock() + ) + + # Test the callback is not called if GOOGLE_API_USE_CLIENT_CERTIFICATE is not set. + is_mtls = authed_http.configure_mtls_channel(callback) + assert not is_mtls + callback.assert_not_called() + + # Test ADC client cert is not used if GOOGLE_API_USE_CLIENT_CERTIFICATE is not set. + is_mtls = authed_http.configure_mtls_channel(callback) + assert not is_mtls + get_client_cert_and_key.assert_not_called() + + def test_clear_pool_on_del(self): + http = mock.create_autospec(urllib3.PoolManager) + authed_http = google.auth.transport.urllib3.AuthorizedHttp( + mock.sentinel.credentials, http=http + ) + authed_http.__del__() + http.clear.assert_called_with() + + authed_http.http = None + authed_http.__del__() + # Expect it to not crash diff --git a/packages/google-auth/tests_async/__init__.py b/packages/google-auth/tests_async/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/google-auth/tests_async/conftest.py b/packages/google-auth/tests_async/conftest.py new file mode 100644 index 000000000000..f13dec0e4f00 --- /dev/null +++ b/packages/google-auth/tests_async/conftest.py @@ -0,0 +1,51 @@ +# 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 os +import sys + +import mock +import pytest # type: ignore + + +def pytest_configure(): + """Load public certificate and private key.""" + pytest.data_dir = os.path.join( + os.path.abspath(os.path.join(__file__, "../..")), "tests/data" + ) + + with open(os.path.join(pytest.data_dir, "privatekey.pem"), "rb") as fh: + pytest.private_key_bytes = fh.read() + + with open(os.path.join(pytest.data_dir, "public_cert.pem"), "rb") as fh: + pytest.public_cert_bytes = fh.read() + + +@pytest.fixture +def mock_non_existent_module(monkeypatch): + """Mocks a non-existing module in sys.modules. + + Additionally mocks any non-existing modules specified in the dotted path. + """ + + def _mock_non_existent_module(path): + parts = path.split(".") + partial = [] + for part in parts: + partial.append(part) + current_module = ".".join(partial) + if current_module not in sys.modules: + monkeypatch.setitem(sys.modules, current_module, mock.MagicMock()) + + return _mock_non_existent_module diff --git a/packages/google-auth/tests_async/oauth2/test__client_async.py b/packages/google-auth/tests_async/oauth2/test__client_async.py new file mode 100644 index 000000000000..d4b8d3427384 --- /dev/null +++ b/packages/google-auth/tests_async/oauth2/test__client_async.py @@ -0,0 +1,494 @@ +# 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 datetime +import http.client as http_client +import json +import urllib + +import mock +import pytest # type: ignore + +from google.auth import _helpers +from google.auth import _jwt_async as jwt +from google.auth import exceptions +from google.oauth2 import _client as sync_client +from google.oauth2 import _client_async as _client +from tests.oauth2 import test__client as test_client + + +def make_request(response_data, status=http_client.OK, text=False): + response = mock.AsyncMock(spec=["transport.Response"]) + response.status = status + data = response_data if text else json.dumps(response_data).encode("utf-8") + response.data = mock.AsyncMock(spec=["__call__", "read"]) + response.data.read = mock.AsyncMock(spec=["__call__"], return_value=data) + response.content = mock.AsyncMock(spec=["__call__"], return_value=data) + request = mock.AsyncMock(spec=["transport.Request"]) + request.return_value = response + return request + + +@pytest.mark.asyncio +async def test__token_endpoint_request(): + request = make_request({"test": "response"}) + + result = await _client._token_endpoint_request( + request, "http://example.com", {"test": "params"} + ) + + # Check request call + request.assert_called_with( + method="POST", + url="http://example.com", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + body="test=params".encode("utf-8"), + ) + + # Check result + assert result == {"test": "response"} + + +@pytest.mark.asyncio +async def test__token_endpoint_request_text(): + request = make_request("response", text=True) + + result = await _client._token_endpoint_request( + request, "http://example.com", {"test": "params"} + ) + + # Check request call + request.assert_called_with( + method="POST", + url="http://example.com", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + body="test=params".encode("utf-8"), + ) + + # Check result + assert result == "response" + + +@pytest.mark.asyncio +async def test__token_endpoint_request_json(): + request = make_request({"test": "response"}) + access_token = "access_token" + + result = await _client._token_endpoint_request( + request, + "http://example.com", + {"test": "params"}, + access_token=access_token, + use_json=True, + ) + + # Check request call + request.assert_called_with( + method="POST", + url="http://example.com", + headers={ + "Content-Type": "application/json", + "Authorization": "Bearer access_token", + }, + body=b'{"test": "params"}', + ) + + # Check result + assert result == {"test": "response"} + + +@pytest.mark.asyncio +async def test__token_endpoint_request_error(): + request = make_request({}, status=http_client.BAD_REQUEST) + + with pytest.raises(exceptions.RefreshError) as excinfo: + await _client._token_endpoint_request(request, "http://example.com", {}) + assert not excinfo.value.retryable + + +@pytest.mark.asyncio +async def test__token_endpoint_request_internal_failure_error(): + request = make_request( + {"error_description": "internal_failure"}, status=http_client.BAD_REQUEST + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + await _client._token_endpoint_request( + request, "http://example.com", {"error_description": "internal_failure"} + ) + assert excinfo.value.retryable + + request = make_request( + {"error": "internal_failure"}, status=http_client.BAD_REQUEST + ) + + with pytest.raises(exceptions.RefreshError): + await _client._token_endpoint_request( + request, "http://example.com", {"error": "internal_failure"} + ) + assert excinfo.value.retryable + + +@pytest.mark.asyncio +async def test__token_endpoint_request_internal_failure_and_retry_failure_error(): + retryable_error = mock.AsyncMock(spec=["transport.Response"]) + retryable_error.status = http_client.BAD_REQUEST + data = json.dumps({"error_description": "internal_failure"}).encode("utf-8") + retryable_error.data = mock.AsyncMock(spec=["__call__", "read"]) + retryable_error.data.read = mock.AsyncMock(spec=["__call__"], return_value=data) + retryable_error.content = mock.AsyncMock(spec=["__call__"], return_value=data) + + unretryable_error = mock.AsyncMock(spec=["transport.Response"]) + unretryable_error.status = http_client.BAD_REQUEST + data = json.dumps({"error_description": "invalid_scope"}).encode("utf-8") + unretryable_error.data = mock.AsyncMock(spec=["__call__", "read"]) + unretryable_error.data.read = mock.AsyncMock(spec=["__call__"], return_value=data) + unretryable_error.content = mock.AsyncMock(spec=["__call__"], return_value=data) + + request = mock.AsyncMock(spec=["transport.Request"]) + request.side_effect = [retryable_error, retryable_error, unretryable_error] + + with pytest.raises(exceptions.RefreshError): + await _client._token_endpoint_request( + request, "http://example.com", {"error_description": "invalid_scope"} + ) + # request should be called three times. Two retryable errors and one + # unretryable error to break the retry loop. + assert request.call_count == 3 + + +@pytest.mark.asyncio +async def test__token_endpoint_request_internal_failure_and_retry_succeeds(): + retryable_error = mock.AsyncMock(spec=["transport.Response"]) + retryable_error.status = http_client.BAD_REQUEST + data = json.dumps({"error_description": "internal_failure"}).encode("utf-8") + retryable_error.data = mock.AsyncMock(spec=["__call__", "read"]) + retryable_error.data.read = mock.AsyncMock(spec=["__call__"], return_value=data) + retryable_error.content = mock.AsyncMock(spec=["__call__"], return_value=data) + + response = mock.AsyncMock(spec=["transport.Response"]) + response.status = http_client.OK + data = json.dumps({"hello": "world"}).encode("utf-8") + response.data = mock.AsyncMock(spec=["__call__", "read"]) + response.data.read = mock.AsyncMock(spec=["__call__"], return_value=data) + response.content = mock.AsyncMock(spec=["__call__"], return_value=data) + + request = mock.AsyncMock(spec=["transport.Request"]) + request.side_effect = [retryable_error, response] + + _ = await _client._token_endpoint_request( + request, "http://example.com", {"test": "params"} + ) + + assert request.call_count == 2 + + +def verify_request_params(request, params): + request_body = request.call_args[1]["body"].decode("utf-8") + request_params = urllib.parse.parse_qs(request_body) + + for key, value in params.items(): + assert request_params[key][0] == value + + +@pytest.mark.asyncio +@mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) +async def test_jwt_grant(utcnow): + request = make_request( + {"access_token": "token", "expires_in": 500, "extra": "data"} + ) + + token, expiry, extra_data = await _client.jwt_grant( + request, "http://example.com", "assertion_value" + ) + + # Check request call + verify_request_params( + request, + {"grant_type": sync_client._JWT_GRANT_TYPE, "assertion": "assertion_value"}, + ) + + # Check result + assert token == "token" + assert expiry == utcnow() + datetime.timedelta(seconds=500) + assert extra_data["extra"] == "data" + + +@pytest.mark.asyncio +async def test_jwt_grant_no_access_token(): + request = make_request( + { + # No access token. + "expires_in": 500, + "extra": "data", + } + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + await _client.jwt_grant(request, "http://example.com", "assertion_value") + assert not excinfo.value.retryable + + +@pytest.mark.asyncio +async def test_id_token_jwt_grant(): + now = _helpers.utcnow() + id_token_expiry = _helpers.datetime_to_secs(now) + id_token = jwt.encode(test_client.SIGNER, {"exp": id_token_expiry}).decode("utf-8") + request = make_request({"id_token": id_token, "extra": "data"}) + + token, expiry, extra_data = await _client.id_token_jwt_grant( + request, "http://example.com", "assertion_value" + ) + + # Check request call + verify_request_params( + request, + {"grant_type": sync_client._JWT_GRANT_TYPE, "assertion": "assertion_value"}, + ) + + # Check result + assert token == id_token + # JWT does not store microseconds + now = now.replace(microsecond=0) + assert expiry == now + assert extra_data["extra"] == "data" + + +@pytest.mark.asyncio +async def test_id_token_jwt_grant_no_access_token(): + request = make_request( + { + # No access token. + "expires_in": 500, + "extra": "data", + } + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + await _client.id_token_jwt_grant( + request, "http://example.com", "assertion_value" + ) + assert not excinfo.value.retryable + + +@pytest.mark.asyncio +@mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) +async def test_refresh_grant(unused_utcnow): + request = make_request( + { + "access_token": "token", + "refresh_token": "new_refresh_token", + "expires_in": 500, + "extra": "data", + } + ) + + token, refresh_token, expiry, extra_data = await _client.refresh_grant( + request, + "http://example.com", + "refresh_token", + "client_id", + "client_secret", + rapt_token="rapt_token", + ) + + # Check request call + verify_request_params( + request, + { + "grant_type": sync_client._REFRESH_GRANT_TYPE, + "refresh_token": "refresh_token", + "client_id": "client_id", + "client_secret": "client_secret", + "rapt": "rapt_token", + }, + ) + + # Check result + assert token == "token" + assert refresh_token == "new_refresh_token" + assert expiry == datetime.datetime.min + datetime.timedelta(seconds=500) + assert extra_data["extra"] == "data" + + +@pytest.mark.asyncio +@mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) +async def test_refresh_grant_with_scopes(unused_utcnow): + request = make_request( + { + "access_token": "token", + "refresh_token": "new_refresh_token", + "expires_in": 500, + "extra": "data", + "scope": test_client.SCOPES_AS_STRING, + } + ) + + token, refresh_token, expiry, extra_data = await _client.refresh_grant( + request, + "http://example.com", + "refresh_token", + "client_id", + "client_secret", + test_client.SCOPES_AS_LIST, + ) + + # Check request call. + verify_request_params( + request, + { + "grant_type": sync_client._REFRESH_GRANT_TYPE, + "refresh_token": "refresh_token", + "client_id": "client_id", + "client_secret": "client_secret", + "scope": test_client.SCOPES_AS_STRING, + }, + ) + + # Check result. + assert token == "token" + assert refresh_token == "new_refresh_token" + assert expiry == datetime.datetime.min + datetime.timedelta(seconds=500) + assert extra_data["extra"] == "data" + + +@pytest.mark.asyncio +async def test_refresh_grant_no_access_token(): + request = make_request( + { + # No access token. + "refresh_token": "new_refresh_token", + "expires_in": 500, + "extra": "data", + } + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + await _client.refresh_grant( + request, "http://example.com", "refresh_token", "client_id", "client_secret" + ) + assert not excinfo.value.retryable + + +@pytest.mark.asyncio +@mock.patch("google.oauth2._client._parse_expiry", return_value=None) +@mock.patch.object(_client, "_token_endpoint_request", autospec=True) +async def test_jwt_grant_retry_default(mock_token_endpoint_request, mock_expiry): + _ = await _client.jwt_grant(mock.Mock(), mock.Mock(), mock.Mock()) + mock_token_endpoint_request.assert_called_with( + mock.ANY, mock.ANY, mock.ANY, can_retry=True + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("can_retry", [True, False]) +@mock.patch("google.oauth2._client._parse_expiry", return_value=None) +@mock.patch.object(_client, "_token_endpoint_request", autospec=True) +async def test_jwt_grant_retry_with_retry( + mock_token_endpoint_request, mock_expiry, can_retry +): + _ = await _client.jwt_grant( + mock.AsyncMock(), mock.Mock(), mock.Mock(), can_retry=can_retry + ) + mock_token_endpoint_request.assert_called_with( + mock.ANY, mock.ANY, mock.ANY, can_retry=can_retry + ) + + +@pytest.mark.asyncio +@mock.patch("google.auth.jwt.decode", return_value={"exp": 0}) +@mock.patch.object(_client, "_token_endpoint_request", autospec=True) +async def test_id_token_jwt_grant_retry_default( + mock_token_endpoint_request, mock_jwt_decode +): + _ = await _client.id_token_jwt_grant(mock.Mock(), mock.Mock(), mock.Mock()) + mock_token_endpoint_request.assert_called_with( + mock.ANY, mock.ANY, mock.ANY, can_retry=True + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("can_retry", [True, False]) +@mock.patch("google.auth.jwt.decode", return_value={"exp": 0}) +@mock.patch.object(_client, "_token_endpoint_request", autospec=True) +async def test_id_token_jwt_grant_retry_with_retry( + mock_token_endpoint_request, mock_jwt_decode, can_retry +): + _ = await _client.id_token_jwt_grant( + mock.AsyncMock(), mock.AsyncMock(), mock.AsyncMock(), can_retry=can_retry + ) + mock_token_endpoint_request.assert_called_with( + mock.ANY, mock.ANY, mock.ANY, can_retry=can_retry + ) + + +@pytest.mark.asyncio +@mock.patch("google.oauth2._client._parse_expiry", return_value=None) +@mock.patch.object(_client, "_token_endpoint_request", autospec=True) +async def test_refresh_grant_retry_default( + mock_token_endpoint_request, mock_parse_expiry +): + _ = await _client.refresh_grant( + mock.AsyncMock(), + mock.AsyncMock(), + mock.AsyncMock(), + mock.AsyncMock(), + mock.AsyncMock(), + ) + mock_token_endpoint_request.assert_called_with( + mock.ANY, mock.ANY, mock.ANY, can_retry=True + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("can_retry", [True, False]) +@mock.patch("google.oauth2._client._parse_expiry", return_value=None) +@mock.patch.object(_client, "_token_endpoint_request", autospec=True) +async def test_refresh_grant_retry_with_retry( + mock_token_endpoint_request, mock_parse_expiry, can_retry +): + _ = await _client.refresh_grant( + mock.AsyncMock(), + mock.AsyncMock(), + mock.AsyncMock(), + mock.AsyncMock(), + mock.AsyncMock(), + can_retry=can_retry, + ) + mock_token_endpoint_request.assert_called_with( + mock.ANY, mock.ANY, mock.ANY, can_retry=can_retry + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("can_retry", [True, False]) +async def test__token_endpoint_request_no_throw_with_retry(can_retry): + mock_request = make_request( + {"error": "help", "error_description": "I'm alive"}, + http_client.INTERNAL_SERVER_ERROR, + ) + + _ = await _client._token_endpoint_request_no_throw( + mock_request, + mock.AsyncMock(), + "body", + mock.AsyncMock(), + mock.AsyncMock(), + can_retry=can_retry, + ) + + if can_retry: + assert mock_request.call_count == 3 + else: + assert mock_request.call_count == 1 diff --git a/packages/google-auth/tests_async/oauth2/test_credentials_async.py b/packages/google-auth/tests_async/oauth2/test_credentials_async.py new file mode 100644 index 000000000000..4930a70a7591 --- /dev/null +++ b/packages/google-auth/tests_async/oauth2/test_credentials_async.py @@ -0,0 +1,526 @@ +# 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 datetime +import json +import os +import pickle +import sys + +import mock +import pytest # type: ignore + +from google.auth import _helpers +from google.auth import exceptions +from google.oauth2 import _credentials_async as _credentials_async +from google.oauth2 import credentials +from tests.oauth2 import test_credentials + + +class TestCredentials: + TOKEN_URI = "https://example.com/oauth2/token" + REFRESH_TOKEN = "refresh_token" + CLIENT_ID = "client_id" + CLIENT_SECRET = "client_secret" + + @classmethod + def make_credentials(cls): + return _credentials_async.Credentials( + token=None, + refresh_token=cls.REFRESH_TOKEN, + token_uri=cls.TOKEN_URI, + client_id=cls.CLIENT_ID, + client_secret=cls.CLIENT_SECRET, + enable_reauth_refresh=True, + ) + + def test_default_state(self): + credentials = self.make_credentials() + assert not credentials.valid + # Expiration hasn't been set yet + assert not credentials.expired + # Scopes aren't required for these credentials + assert not credentials.requires_scopes + # Test properties + assert credentials.refresh_token == self.REFRESH_TOKEN + assert credentials.token_uri == self.TOKEN_URI + assert credentials.client_id == self.CLIENT_ID + assert credentials.client_secret == self.CLIENT_SECRET + + @mock.patch("google.oauth2._reauth_async.refresh_grant", autospec=True) + @mock.patch( + "google.auth._helpers.utcnow", + return_value=datetime.datetime.min + _helpers.REFRESH_THRESHOLD, + ) + @pytest.mark.asyncio + async def test_refresh_success(self, unused_utcnow, refresh_grant): + token = "token" + expiry = _helpers.utcnow() + datetime.timedelta(seconds=500) + grant_response = {"id_token": mock.sentinel.id_token} + rapt_token = "rapt_token" + refresh_grant.return_value = ( + # Access token + token, + # New refresh token + None, + # Expiry, + expiry, + # Extra data + grant_response, + # Rapt token + rapt_token, + ) + + request = mock.AsyncMock(spec=["transport.Request"]) + creds = self.make_credentials() + + # Refresh credentials + await creds.refresh(request) + + # Check jwt grant call. + refresh_grant.assert_called_with( + request, + self.TOKEN_URI, + self.REFRESH_TOKEN, + self.CLIENT_ID, + self.CLIENT_SECRET, + None, + None, + True, + ) + + # Check that the credentials have the token and expiry + assert creds.token == token + assert creds.expiry == expiry + assert creds.id_token == mock.sentinel.id_token + assert creds.rapt_token == rapt_token + + # Check that the credentials are valid (have a token and are not + # expired) + assert creds.valid + + @pytest.mark.asyncio + async def test_refresh_no_refresh_token(self): + request = mock.AsyncMock(spec=["transport.Request"]) + credentials_ = _credentials_async.Credentials(token=None, refresh_token=None) + + with pytest.raises(exceptions.RefreshError, match="necessary fields"): + await credentials_.refresh(request) + + request.assert_not_called() + + @mock.patch("google.oauth2._reauth_async.refresh_grant", autospec=True) + @mock.patch( + "google.auth._helpers.utcnow", + return_value=datetime.datetime.min + _helpers.REFRESH_THRESHOLD, + ) + @pytest.mark.asyncio + async def test_credentials_with_scopes_requested_refresh_success( + self, unused_utcnow, refresh_grant + ): + scopes = ["email", "profile"] + token = "token" + expiry = _helpers.utcnow() + datetime.timedelta(seconds=500) + grant_response = {"id_token": mock.sentinel.id_token} + rapt_token = "rapt_token" + refresh_grant.return_value = ( + # Access token + token, + # New refresh token + None, + # Expiry, + expiry, + # Extra data + grant_response, + # Rapt token + rapt_token, + ) + + request = mock.AsyncMock(spec=["transport.Request"]) + creds = _credentials_async.Credentials( + token=None, + refresh_token=self.REFRESH_TOKEN, + token_uri=self.TOKEN_URI, + client_id=self.CLIENT_ID, + client_secret=self.CLIENT_SECRET, + scopes=scopes, + rapt_token="old_rapt_token", + ) + + # Refresh credentials + await creds.refresh(request) + + # Check jwt grant call. + refresh_grant.assert_called_with( + request, + self.TOKEN_URI, + self.REFRESH_TOKEN, + self.CLIENT_ID, + self.CLIENT_SECRET, + scopes, + "old_rapt_token", + False, + ) + + # Check that the credentials have the token and expiry + assert creds.token == token + assert creds.expiry == expiry + assert creds.id_token == mock.sentinel.id_token + assert creds.has_scopes(scopes) + assert creds.rapt_token == rapt_token + + # Check that the credentials are valid (have a token and are not + # expired.) + assert creds.valid + + @mock.patch("google.oauth2._reauth_async.refresh_grant", autospec=True) + @mock.patch( + "google.auth._helpers.utcnow", + return_value=datetime.datetime.min + _helpers.REFRESH_THRESHOLD, + ) + @pytest.mark.asyncio + async def test_credentials_with_scopes_returned_refresh_success( + self, unused_utcnow, refresh_grant + ): + scopes = ["email", "profile"] + token = "token" + expiry = _helpers.utcnow() + datetime.timedelta(seconds=500) + grant_response = {"id_token": mock.sentinel.id_token, "scope": " ".join(scopes)} + rapt_token = "rapt_token" + refresh_grant.return_value = ( + # Access token + token, + # New refresh token + None, + # Expiry, + expiry, + # Extra data + grant_response, + # Rapt token + rapt_token, + ) + + request = mock.AsyncMock(spec=["transport.Request"]) + creds = _credentials_async.Credentials( + token=None, + refresh_token=self.REFRESH_TOKEN, + token_uri=self.TOKEN_URI, + client_id=self.CLIENT_ID, + client_secret=self.CLIENT_SECRET, + scopes=scopes, + ) + + # Refresh credentials + await creds.refresh(request) + + # Check jwt grant call. + refresh_grant.assert_called_with( + request, + self.TOKEN_URI, + self.REFRESH_TOKEN, + self.CLIENT_ID, + self.CLIENT_SECRET, + scopes, + None, + False, + ) + + # Check that the credentials have the token and expiry + assert creds.token == token + assert creds.expiry == expiry + assert creds.id_token == mock.sentinel.id_token + assert creds.has_scopes(scopes) + assert creds.rapt_token == rapt_token + + # Check that the credentials are valid (have a token and are not + # expired.) + assert creds.valid + + @mock.patch("google.oauth2._reauth_async.refresh_grant", autospec=True) + @mock.patch( + "google.auth._helpers.utcnow", + return_value=datetime.datetime.min + _helpers.REFRESH_THRESHOLD, + ) + @pytest.mark.asyncio + async def test_credentials_with_scopes_refresh_failure_raises_refresh_error( + self, unused_utcnow, refresh_grant + ): + scopes = ["email", "profile"] + scopes_returned = ["email"] + token = "token" + expiry = _helpers.utcnow() + datetime.timedelta(seconds=500) + grant_response = { + "id_token": mock.sentinel.id_token, + "scope": " ".join(scopes_returned), + } + rapt_token = "rapt_token" + refresh_grant.return_value = ( + # Access token + token, + # New refresh token + None, + # Expiry, + expiry, + # Extra data + grant_response, + # Rapt token + rapt_token, + ) + + request = mock.AsyncMock(spec=["transport.Request"]) + creds = _credentials_async.Credentials( + token=None, + refresh_token=self.REFRESH_TOKEN, + token_uri=self.TOKEN_URI, + client_id=self.CLIENT_ID, + client_secret=self.CLIENT_SECRET, + scopes=scopes, + rapt_token=None, + ) + + # Refresh credentials + with pytest.raises( + exceptions.RefreshError, match="Not all requested scopes were granted" + ): + await creds.refresh(request) + + # Check jwt grant call. + refresh_grant.assert_called_with( + request, + self.TOKEN_URI, + self.REFRESH_TOKEN, + self.CLIENT_ID, + self.CLIENT_SECRET, + scopes, + None, + False, + ) + + # Check that the credentials have the token and expiry + assert creds.token == token + assert creds.expiry == expiry + assert creds.id_token == mock.sentinel.id_token + assert creds.has_scopes(scopes) + + # Check that the credentials are valid (have a token and are not + # expired.) + assert creds.valid + + def test_apply_with_quota_project_id(self): + creds = _credentials_async.Credentials( + token="token", + refresh_token=self.REFRESH_TOKEN, + token_uri=self.TOKEN_URI, + client_id=self.CLIENT_ID, + client_secret=self.CLIENT_SECRET, + quota_project_id="quota-project-123", + ) + + headers = {} + creds.apply(headers) + assert headers["x-goog-user-project"] == "quota-project-123" + + def test_apply_with_no_quota_project_id(self): + creds = _credentials_async.Credentials( + token="token", + refresh_token=self.REFRESH_TOKEN, + token_uri=self.TOKEN_URI, + client_id=self.CLIENT_ID, + client_secret=self.CLIENT_SECRET, + ) + + headers = {} + creds.apply(headers) + assert "x-goog-user-project" not in headers + + def test_with_quota_project(self): + creds = _credentials_async.Credentials( + token="token", + refresh_token=self.REFRESH_TOKEN, + token_uri=self.TOKEN_URI, + client_id=self.CLIENT_ID, + client_secret=self.CLIENT_SECRET, + quota_project_id="quota-project-123", + ) + + new_creds = creds.with_quota_project("new-project-456") + assert new_creds.quota_project_id == "new-project-456" + headers = {} + creds.apply(headers) + assert "x-goog-user-project" in headers + + def test_from_authorized_user_info(self): + info = test_credentials.AUTH_USER_INFO.copy() + + creds = _credentials_async.Credentials.from_authorized_user_info(info) + assert creds.client_secret == info["client_secret"] + assert creds.client_id == info["client_id"] + assert creds.refresh_token == info["refresh_token"] + assert creds.token_uri == credentials._GOOGLE_OAUTH2_TOKEN_ENDPOINT + assert creds.scopes is None + + scopes = ["email", "profile"] + creds = _credentials_async.Credentials.from_authorized_user_info(info, scopes) + assert creds.client_secret == info["client_secret"] + assert creds.client_id == info["client_id"] + assert creds.refresh_token == info["refresh_token"] + assert creds.token_uri == credentials._GOOGLE_OAUTH2_TOKEN_ENDPOINT + assert creds.scopes == scopes + + def test_from_authorized_user_file(self): + info = test_credentials.AUTH_USER_INFO.copy() + + creds = _credentials_async.Credentials.from_authorized_user_file( + test_credentials.AUTH_USER_JSON_FILE + ) + assert creds.client_secret == info["client_secret"] + assert creds.client_id == info["client_id"] + assert creds.refresh_token == info["refresh_token"] + assert creds.token_uri == credentials._GOOGLE_OAUTH2_TOKEN_ENDPOINT + assert creds.scopes is None + + scopes = ["email", "profile"] + creds = _credentials_async.Credentials.from_authorized_user_file( + test_credentials.AUTH_USER_JSON_FILE, scopes + ) + assert creds.client_secret == info["client_secret"] + assert creds.client_id == info["client_id"] + assert creds.refresh_token == info["refresh_token"] + assert creds.token_uri == credentials._GOOGLE_OAUTH2_TOKEN_ENDPOINT + assert creds.scopes == scopes + + def test_to_json(self): + info = test_credentials.AUTH_USER_INFO.copy() + creds = _credentials_async.Credentials.from_authorized_user_info(info) + + # Test with no `strip` arg + json_output = creds.to_json() + json_asdict = json.loads(json_output) + assert json_asdict.get("token") == creds.token + assert json_asdict.get("refresh_token") == creds.refresh_token + assert json_asdict.get("token_uri") == creds.token_uri + assert json_asdict.get("client_id") == creds.client_id + assert json_asdict.get("scopes") == creds.scopes + assert json_asdict.get("client_secret") == creds.client_secret + + # Test with a `strip` arg + json_output = creds.to_json(strip=["client_secret"]) + json_asdict = json.loads(json_output) + assert json_asdict.get("token") == creds.token + assert json_asdict.get("refresh_token") == creds.refresh_token + assert json_asdict.get("token_uri") == creds.token_uri + assert json_asdict.get("client_id") == creds.client_id + assert json_asdict.get("scopes") == creds.scopes + assert json_asdict.get("client_secret") is None + + def test_pickle_and_unpickle(self): + creds = self.make_credentials() + unpickled = pickle.loads(pickle.dumps(creds)) + + # make sure attributes aren't lost during pickling + assert list(creds.__dict__).sort() == list(unpickled.__dict__).sort() + + for attr in list(creds.__dict__): + if attr == "_refresh_worker": + assert getattr(unpickled, attr) is None + else: + assert getattr(creds, attr) == getattr(unpickled, attr) + + def test_pickle_with_missing_attribute(self): + creds = self.make_credentials() + + # remove an optional attribute before pickling + # this mimics a pickle created with a previous class definition with + # fewer attributes + del creds.__dict__["_quota_project_id"] + + unpickled = pickle.loads(pickle.dumps(creds)) + + # Attribute should be initialized by `__setstate__` + assert unpickled.quota_project_id is None + + # pickles are not compatible across versions + @pytest.mark.skipif( + sys.version_info < (3, 5), + reason="pickle file can only be loaded with Python >= 3.5", + ) + def test_unpickle_old_credentials_pickle(self): + # make sure a credentials file pickled with an older + # library version (google-auth==1.5.1) can be unpickled + with open( + os.path.join(test_credentials.DATA_DIR, "old_oauth_credentials_py3.pickle"), + "rb", + ) as f: + credentials = pickle.load(f) + assert credentials.quota_project_id is None + + @mock.patch("google.oauth2._credentials_async.Credentials.apply", autospec=True) + @mock.patch("google.oauth2._credentials_async.Credentials.refresh", autospec=True) + @pytest.mark.asyncio + async def test_before_request(self, refresh, apply): + cred = self.make_credentials() + assert not cred.valid + await cred.before_request(mock.Mock(), "GET", "https://example.com", {}) + refresh.assert_called() + apply.assert_called() + + @mock.patch("google.oauth2._credentials_async.Credentials.apply", autospec=True) + @mock.patch("google.oauth2._credentials_async.Credentials.refresh", autospec=True) + @pytest.mark.asyncio + async def test_before_request_no_refresh(self, refresh, apply): + cred = self.make_credentials() + cred.token = refresh + cred.expiry = None + + assert cred.valid + await cred.before_request(mock.Mock(), "GET", "https://example.com", {}) + refresh.assert_not_called() + apply.assert_called() + + +class TestUserAccessTokenCredentials(object): + def test_instance(self): + cred = _credentials_async.UserAccessTokenCredentials() + assert cred._account is None + + cred = cred.with_account("account") + assert cred._account == "account" + + @mock.patch("google.auth._cloud_sdk.get_auth_access_token", autospec=True) + def test_refresh(self, get_auth_access_token): + get_auth_access_token.return_value = "access_token" + cred = _credentials_async.UserAccessTokenCredentials() + cred.refresh(None) + assert cred.token == "access_token" + + def test_with_quota_project(self): + cred = _credentials_async.UserAccessTokenCredentials() + quota_project_cred = cred.with_quota_project("project-foo") + + assert quota_project_cred._quota_project_id == "project-foo" + assert quota_project_cred._account == cred._account + + @mock.patch( + "google.oauth2._credentials_async.UserAccessTokenCredentials.apply", + autospec=True, + ) + @mock.patch( + "google.oauth2._credentials_async.UserAccessTokenCredentials.refresh", + autospec=True, + ) + def test_before_request(self, refresh, apply): + cred = _credentials_async.UserAccessTokenCredentials() + cred.before_request(mock.Mock(), "GET", "https://example.com", {}) + refresh.assert_called() + apply.assert_called() diff --git a/packages/google-auth/tests_async/oauth2/test_id_token.py b/packages/google-auth/tests_async/oauth2/test_id_token.py new file mode 100644 index 000000000000..9be086dca5b1 --- /dev/null +++ b/packages/google-auth/tests_async/oauth2/test_id_token.py @@ -0,0 +1,315 @@ +# Copyright 2020 Google Inc. +# +# 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 json +import os + +import mock +import pytest # type: ignore + +from google.auth import environment_vars +from google.auth import exceptions +import google.auth.compute_engine._metadata +from google.oauth2 import _id_token_async as id_token +from google.oauth2 import _service_account_async +from google.oauth2 import id_token as sync_id_token +from tests.oauth2 import test_id_token + + +def make_request(status, data=None): + response = mock.AsyncMock(spec=["transport.Response"]) + response.status = status + + if data is not None: + response.data = mock.AsyncMock(spec=["__call__", "read"]) + response.content = mock.AsyncMock( + spec=["__call__"], return_value=json.dumps(data).encode("utf-8") + ) + + request = mock.AsyncMock(spec=["transport.Request"]) + request.return_value = response + return request + + +@pytest.mark.asyncio +async def test__fetch_certs_success(): + certs = {"1": "cert"} + request = make_request(200, certs) + + returned_certs = await id_token._fetch_certs(request, mock.sentinel.cert_url) + + request.assert_called_once_with(mock.sentinel.cert_url, method="GET") + assert returned_certs == certs + + +@pytest.mark.asyncio +async def test__fetch_certs_failure(): + request = make_request(404) + + with pytest.raises(exceptions.TransportError): + await id_token._fetch_certs(request, mock.sentinel.cert_url) + + request.assert_called_once_with(mock.sentinel.cert_url, method="GET") + + +@mock.patch("google.auth.jwt.decode", autospec=True) +@mock.patch("google.oauth2._id_token_async._fetch_certs", autospec=True) +@pytest.mark.asyncio +async def test_verify_token(_fetch_certs, decode): + result = await id_token.verify_token(mock.sentinel.token, mock.sentinel.request) + + assert result == decode.return_value + _fetch_certs.assert_called_once_with( + mock.sentinel.request, sync_id_token._GOOGLE_OAUTH2_CERTS_URL + ) + decode.assert_called_once_with( + mock.sentinel.token, + certs=_fetch_certs.return_value, + audience=None, + clock_skew_in_seconds=0, + ) + + +@mock.patch("google.auth.jwt.decode", autospec=True) +@mock.patch("google.oauth2._id_token_async._fetch_certs", autospec=True) +@pytest.mark.asyncio +async def test_verify_token_clock_skew(_fetch_certs, decode): + result = await id_token.verify_token( + mock.sentinel.token, mock.sentinel.request, clock_skew_in_seconds=10 + ) + + assert result == decode.return_value + _fetch_certs.assert_called_once_with( + mock.sentinel.request, sync_id_token._GOOGLE_OAUTH2_CERTS_URL + ) + decode.assert_called_once_with( + mock.sentinel.token, + certs=_fetch_certs.return_value, + audience=None, + clock_skew_in_seconds=10, + ) + + +@mock.patch("google.auth.jwt.decode", autospec=True) +@mock.patch("google.oauth2._id_token_async._fetch_certs", autospec=True) +@pytest.mark.asyncio +async def test_verify_token_args(_fetch_certs, decode): + result = await id_token.verify_token( + mock.sentinel.token, + mock.sentinel.request, + audience=mock.sentinel.audience, + certs_url=mock.sentinel.certs_url, + ) + + assert result == decode.return_value + _fetch_certs.assert_called_once_with(mock.sentinel.request, mock.sentinel.certs_url) + decode.assert_called_once_with( + mock.sentinel.token, + certs=_fetch_certs.return_value, + audience=mock.sentinel.audience, + clock_skew_in_seconds=0, + ) + + +@mock.patch("google.oauth2._id_token_async.verify_token", autospec=True) +@pytest.mark.asyncio +async def test_verify_oauth2_token(verify_token): + verify_token.return_value = {"iss": "accounts.google.com"} + result = await id_token.verify_oauth2_token( + mock.sentinel.token, mock.sentinel.request, audience=mock.sentinel.audience + ) + + assert result == verify_token.return_value + verify_token.assert_called_once_with( + mock.sentinel.token, + mock.sentinel.request, + audience=mock.sentinel.audience, + certs_url=sync_id_token._GOOGLE_OAUTH2_CERTS_URL, + clock_skew_in_seconds=0, + ) + + +@mock.patch("google.oauth2._id_token_async.verify_token", autospec=True) +@pytest.mark.asyncio +async def test_verify_oauth2_token_clock_skew(verify_token): + verify_token.return_value = {"iss": "accounts.google.com"} + result = await id_token.verify_oauth2_token( + mock.sentinel.token, + mock.sentinel.request, + audience=mock.sentinel.audience, + clock_skew_in_seconds=10, + ) + + assert result == verify_token.return_value + verify_token.assert_called_once_with( + mock.sentinel.token, + mock.sentinel.request, + audience=mock.sentinel.audience, + certs_url=sync_id_token._GOOGLE_OAUTH2_CERTS_URL, + clock_skew_in_seconds=10, + ) + + +@mock.patch("google.oauth2._id_token_async.verify_token", autospec=True) +@pytest.mark.asyncio +async def test_verify_oauth2_token_invalid_iss(verify_token): + verify_token.return_value = {"iss": "invalid_issuer"} + + with pytest.raises(exceptions.GoogleAuthError): + await id_token.verify_oauth2_token( + mock.sentinel.token, mock.sentinel.request, audience=mock.sentinel.audience + ) + + +@mock.patch("google.oauth2._id_token_async.verify_token", autospec=True) +@pytest.mark.asyncio +async def test_verify_firebase_token(verify_token): + result = await id_token.verify_firebase_token( + mock.sentinel.token, mock.sentinel.request, audience=mock.sentinel.audience + ) + + assert result == verify_token.return_value + verify_token.assert_called_once_with( + mock.sentinel.token, + mock.sentinel.request, + audience=mock.sentinel.audience, + certs_url=sync_id_token._GOOGLE_APIS_CERTS_URL, + clock_skew_in_seconds=0, + ) + + +@mock.patch("google.oauth2._id_token_async.verify_token", autospec=True) +@pytest.mark.asyncio +async def test_verify_firebase_token_clock_skew(verify_token): + result = await id_token.verify_firebase_token( + mock.sentinel.token, + mock.sentinel.request, + audience=mock.sentinel.audience, + clock_skew_in_seconds=10, + ) + + assert result == verify_token.return_value + verify_token.assert_called_once_with( + mock.sentinel.token, + mock.sentinel.request, + audience=mock.sentinel.audience, + certs_url=sync_id_token._GOOGLE_APIS_CERTS_URL, + clock_skew_in_seconds=10, + ) + + +@pytest.mark.asyncio +async def test_fetch_id_token_from_metadata_server(monkeypatch): + monkeypatch.delenv(environment_vars.CREDENTIALS, raising=False) + + def mock_init(self, request, audience, use_metadata_identity_endpoint): + assert use_metadata_identity_endpoint + self.token = "id_token" + + with mock.patch("google.auth.compute_engine._metadata.ping", return_value=True): + with mock.patch.multiple( + google.auth.compute_engine.IDTokenCredentials, + __init__=mock_init, + refresh=mock.Mock(), + ): + request = mock.AsyncMock() + token = await id_token.fetch_id_token( + request, "https://pubsub.googleapis.com" + ) + assert token == "id_token" + + +@pytest.mark.asyncio +async def test_fetch_id_token_from_explicit_cred_json_file(monkeypatch): + monkeypatch.setenv(environment_vars.CREDENTIALS, test_id_token.SERVICE_ACCOUNT_FILE) + + async def mock_refresh(self, request): + self.token = "id_token" + + with mock.patch.object( + _service_account_async.IDTokenCredentials, "refresh", mock_refresh + ): + request = mock.AsyncMock() + token = await id_token.fetch_id_token(request, "https://pubsub.googleapis.com") + assert token == "id_token" + + +@pytest.mark.asyncio +async def test_fetch_id_token_no_cred_exists(monkeypatch): + monkeypatch.delenv(environment_vars.CREDENTIALS, raising=False) + + with mock.patch( + "google.auth.compute_engine._metadata.ping", + side_effect=exceptions.TransportError(), + ): + with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: + request = mock.AsyncMock() + await id_token.fetch_id_token(request, "https://pubsub.googleapis.com") + assert excinfo.match( + r"Neither metadata server or valid service account credentials are found." + ) + + with mock.patch("google.auth.compute_engine._metadata.ping", return_value=False): + with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: + request = mock.AsyncMock() + await id_token.fetch_id_token(request, "https://pubsub.googleapis.com") + assert excinfo.match( + r"Neither metadata server or valid service account credentials are found." + ) + + +@pytest.mark.asyncio +async def test_fetch_id_token_invalid_cred_file(monkeypatch): + not_json_file = os.path.join( + os.path.dirname(__file__), "../../tests/data/public_cert.pem" + ) + monkeypatch.setenv(environment_vars.CREDENTIALS, not_json_file) + + with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: + request = mock.AsyncMock() + await id_token.fetch_id_token(request, "https://pubsub.googleapis.com") + assert excinfo.match( + r"GOOGLE_APPLICATION_CREDENTIALS is not valid service account credentials." + ) + + +@pytest.mark.asyncio +async def test_fetch_id_token_invalid_cred_type(monkeypatch): + user_credentials_file = os.path.join( + os.path.dirname(__file__), "../../tests/data/authorized_user.json" + ) + monkeypatch.setenv(environment_vars.CREDENTIALS, user_credentials_file) + + with mock.patch("google.auth.compute_engine._metadata.ping", return_value=False): + with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: + request = mock.AsyncMock() + await id_token.fetch_id_token(request, "https://pubsub.googleapis.com") + assert excinfo.match( + r"Neither metadata server or valid service account credentials are found." + ) + + +@pytest.mark.asyncio +async def test_fetch_id_token_invalid_cred_path(monkeypatch): + not_json_file = os.path.join( + os.path.dirname(__file__), "../../tests/data/not_exists.json" + ) + monkeypatch.setenv(environment_vars.CREDENTIALS, not_json_file) + + with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: + request = mock.AsyncMock() + await id_token.fetch_id_token(request, "https://pubsub.googleapis.com") + assert excinfo.match( + r"GOOGLE_APPLICATION_CREDENTIALS path is either not found or invalid." + ) diff --git a/packages/google-auth/tests_async/oauth2/test_reauth_async.py b/packages/google-auth/tests_async/oauth2/test_reauth_async.py new file mode 100644 index 000000000000..40ca92717856 --- /dev/null +++ b/packages/google-auth/tests_async/oauth2/test_reauth_async.py @@ -0,0 +1,355 @@ +# Copyright 2021 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 copy + +import mock +import pytest # type: ignore + +from google.auth import exceptions +from google.oauth2 import _reauth_async +from google.oauth2 import reauth + + +MOCK_REQUEST = mock.AsyncMock(spec=["transport.Request"]) +CHALLENGES_RESPONSE_TEMPLATE = { + "status": "CHALLENGE_REQUIRED", + "sessionId": "123", + "challenges": [ + { + "status": "READY", + "challengeId": 1, + "challengeType": "PASSWORD", + "securityKey": {}, + } + ], +} +CHALLENGES_RESPONSE_AUTHENTICATED = { + "status": "AUTHENTICATED", + "sessionId": "123", + "encodedProofOfReauthToken": "new_rapt_token", +} + + +class MockChallenge(object): + def __init__(self, name, locally_eligible, challenge_input): + self.name = name + self.is_locally_eligible = locally_eligible + self.challenge_input = challenge_input + + def obtain_challenge_input(self, metadata): + return self.challenge_input + + +@pytest.mark.asyncio +async def test__get_challenges(): + with mock.patch( + "google.oauth2._client_async._token_endpoint_request" + ) as mock_token_endpoint_request: + await _reauth_async._get_challenges(MOCK_REQUEST, ["SAML"], "token") + mock_token_endpoint_request.assert_called_with( + MOCK_REQUEST, + reauth._REAUTH_API + ":start", + {"supportedChallengeTypes": ["SAML"]}, + access_token="token", + use_json=True, + ) + + +@pytest.mark.asyncio +async def test__get_challenges_with_scopes(): + with mock.patch( + "google.oauth2._client_async._token_endpoint_request" + ) as mock_token_endpoint_request: + await _reauth_async._get_challenges( + MOCK_REQUEST, ["SAML"], "token", requested_scopes=["scope"] + ) + mock_token_endpoint_request.assert_called_with( + MOCK_REQUEST, + reauth._REAUTH_API + ":start", + { + "supportedChallengeTypes": ["SAML"], + "oauthScopesForDomainPolicyLookup": ["scope"], + }, + access_token="token", + use_json=True, + ) + + +@pytest.mark.asyncio +async def test__send_challenge_result(): + with mock.patch( + "google.oauth2._client_async._token_endpoint_request" + ) as mock_token_endpoint_request: + await _reauth_async._send_challenge_result( + MOCK_REQUEST, "123", "1", {"credential": "password"}, "token" + ) + mock_token_endpoint_request.assert_called_with( + MOCK_REQUEST, + reauth._REAUTH_API + "/123:continue", + { + "sessionId": "123", + "challengeId": "1", + "action": "RESPOND", + "proposalResponse": {"credential": "password"}, + }, + access_token="token", + use_json=True, + ) + + +@pytest.mark.asyncio +async def test__run_next_challenge_not_ready(): + challenges_response = copy.deepcopy(CHALLENGES_RESPONSE_TEMPLATE) + challenges_response["challenges"][0]["status"] = "STATUS_UNSPECIFIED" + assert ( + await _reauth_async._run_next_challenge( + challenges_response, MOCK_REQUEST, "token" + ) + is None + ) + + +@pytest.mark.asyncio +async def test__run_next_challenge_not_supported(): + challenges_response = copy.deepcopy(CHALLENGES_RESPONSE_TEMPLATE) + challenges_response["challenges"][0]["challengeType"] = "CHALLENGE_TYPE_UNSPECIFIED" + with pytest.raises(exceptions.ReauthFailError) as excinfo: + await _reauth_async._run_next_challenge( + challenges_response, MOCK_REQUEST, "token" + ) + assert excinfo.match(r"Unsupported challenge type CHALLENGE_TYPE_UNSPECIFIED") + + +@pytest.mark.asyncio +async def test__run_next_challenge_not_locally_eligible(): + mock_challenge = MockChallenge("PASSWORD", False, "challenge_input") + with mock.patch( + "google.oauth2.challenges.AVAILABLE_CHALLENGES", {"PASSWORD": mock_challenge} + ): + with pytest.raises(exceptions.ReauthFailError) as excinfo: + await _reauth_async._run_next_challenge( + CHALLENGES_RESPONSE_TEMPLATE, MOCK_REQUEST, "token" + ) + assert excinfo.match(r"Challenge PASSWORD is not locally eligible") + + +@pytest.mark.asyncio +async def test__run_next_challenge_no_challenge_input(): + mock_challenge = MockChallenge("PASSWORD", True, None) + with mock.patch( + "google.oauth2.challenges.AVAILABLE_CHALLENGES", {"PASSWORD": mock_challenge} + ): + assert ( + await _reauth_async._run_next_challenge( + CHALLENGES_RESPONSE_TEMPLATE, MOCK_REQUEST, "token" + ) + is None + ) + + +@pytest.mark.asyncio +async def test__run_next_challenge_success(): + mock_challenge = MockChallenge("PASSWORD", True, {"credential": "password"}) + with mock.patch( + "google.oauth2.challenges.AVAILABLE_CHALLENGES", {"PASSWORD": mock_challenge} + ): + with mock.patch( + "google.oauth2._reauth_async._send_challenge_result" + ) as mock_send_challenge_result: + await _reauth_async._run_next_challenge( + CHALLENGES_RESPONSE_TEMPLATE, MOCK_REQUEST, "token" + ) + mock_send_challenge_result.assert_called_with( + MOCK_REQUEST, "123", 1, {"credential": "password"}, "token" + ) + + +@pytest.mark.asyncio +async def test__obtain_rapt_authenticated(): + with mock.patch( + "google.oauth2._reauth_async._get_challenges", + return_value=CHALLENGES_RESPONSE_AUTHENTICATED, + ): + new_rapt_token = await _reauth_async._obtain_rapt(MOCK_REQUEST, "token", None) + assert new_rapt_token == "new_rapt_token" + + +@pytest.mark.asyncio +async def test__obtain_rapt_authenticated_after_run_next_challenge(): + with mock.patch( + "google.oauth2._reauth_async._get_challenges", + return_value=CHALLENGES_RESPONSE_TEMPLATE, + ): + with mock.patch( + "google.oauth2._reauth_async._run_next_challenge", + side_effect=[ + CHALLENGES_RESPONSE_TEMPLATE, + CHALLENGES_RESPONSE_AUTHENTICATED, + ], + ): + with mock.patch("google.oauth2.reauth.is_interactive", return_value=True): + assert ( + await _reauth_async._obtain_rapt(MOCK_REQUEST, "token", None) + == "new_rapt_token" + ) + + +@pytest.mark.asyncio +async def test__obtain_rapt_unsupported_status(): + challenges_response = copy.deepcopy(CHALLENGES_RESPONSE_TEMPLATE) + challenges_response["status"] = "STATUS_UNSPECIFIED" + with mock.patch( + "google.oauth2._reauth_async._get_challenges", return_value=challenges_response + ): + with pytest.raises(exceptions.ReauthFailError) as excinfo: + await _reauth_async._obtain_rapt(MOCK_REQUEST, "token", None) + assert excinfo.match(r"API error: STATUS_UNSPECIFIED") + + +@pytest.mark.asyncio +async def test__obtain_rapt_not_interactive(): + with mock.patch( + "google.oauth2._reauth_async._get_challenges", + return_value=CHALLENGES_RESPONSE_TEMPLATE, + ): + with mock.patch("google.oauth2.reauth.is_interactive", return_value=False): + with pytest.raises(exceptions.ReauthFailError) as excinfo: + await _reauth_async._obtain_rapt(MOCK_REQUEST, "token", None) + assert excinfo.match(r"not in an interactive session") + + +@pytest.mark.asyncio +async def test__obtain_rapt_not_authenticated(): + with mock.patch( + "google.oauth2._reauth_async._get_challenges", + return_value=CHALLENGES_RESPONSE_TEMPLATE, + ): + with mock.patch("google.oauth2.reauth.RUN_CHALLENGE_RETRY_LIMIT", 0): + with pytest.raises(exceptions.ReauthFailError) as excinfo: + await _reauth_async._obtain_rapt(MOCK_REQUEST, "token", None) + assert excinfo.match(r"Reauthentication failed") + + +@pytest.mark.asyncio +async def test_get_rapt_token(): + with mock.patch( + "google.oauth2._client_async.refresh_grant", + return_value=("token", None, None, None), + ) as mock_refresh_grant: + with mock.patch( + "google.oauth2._reauth_async._obtain_rapt", return_value="new_rapt_token" + ) as mock_obtain_rapt: + assert ( + await _reauth_async.get_rapt_token( + MOCK_REQUEST, + "client_id", + "client_secret", + "refresh_token", + "token_uri", + ) + == "new_rapt_token" + ) + mock_refresh_grant.assert_called_with( + request=MOCK_REQUEST, + client_id="client_id", + client_secret="client_secret", + refresh_token="refresh_token", + token_uri="token_uri", + scopes=[reauth._REAUTH_SCOPE], + ) + mock_obtain_rapt.assert_called_with( + MOCK_REQUEST, "token", requested_scopes=None + ) + + +@pytest.mark.asyncio +async def test_refresh_grant_failed(): + with mock.patch( + "google.oauth2._client_async._token_endpoint_request_no_throw" + ) as mock_token_request: + mock_token_request.return_value = (False, {"error": "Bad request"}, True) + with pytest.raises(exceptions.RefreshError) as excinfo: + await _reauth_async.refresh_grant( + MOCK_REQUEST, + "token_uri", + "refresh_token", + "client_id", + "client_secret", + scopes=["foo", "bar"], + rapt_token="rapt_token", + ) + assert excinfo.match(r"Bad request") + assert excinfo.value.retryable + mock_token_request.assert_called_with( + MOCK_REQUEST, + "token_uri", + { + "grant_type": "refresh_token", + "client_id": "client_id", + "client_secret": "client_secret", + "refresh_token": "refresh_token", + "scope": "foo bar", + "rapt": "rapt_token", + }, + ) + + +@pytest.mark.asyncio +async def test_refresh_grant_success(): + with mock.patch( + "google.oauth2._client_async._token_endpoint_request_no_throw" + ) as mock_token_request: + mock_token_request.side_effect = [ + (False, {"error": "invalid_grant", "error_subtype": "rapt_required"}, True), + (True, {"access_token": "access_token"}, None), + ] + with mock.patch( + "google.oauth2._reauth_async.get_rapt_token", return_value="new_rapt_token" + ): + assert await _reauth_async.refresh_grant( + MOCK_REQUEST, + "token_uri", + "refresh_token", + "client_id", + "client_secret", + enable_reauth_refresh=True, + ) == ( + "access_token", + "refresh_token", + None, + {"access_token": "access_token"}, + "new_rapt_token", + ) + + +@pytest.mark.asyncio +async def test_refresh_grant_reauth_refresh_disabled(): + with mock.patch( + "google.oauth2._client_async._token_endpoint_request_no_throw" + ) as mock_token_request: + mock_token_request.side_effect = [ + ( + False, + {"error": "invalid_grant", "error_subtype": "rapt_required"}, + False, + ), + (True, {"access_token": "access_token"}, None), + ] + with pytest.raises(exceptions.RefreshError) as excinfo: + assert await _reauth_async.refresh_grant( + MOCK_REQUEST, "token_uri", "refresh_token", "client_id", "client_secret" + ) + assert excinfo.match(r"Reauthentication is needed") + assert not excinfo.value.retryable diff --git a/packages/google-auth/tests_async/oauth2/test_service_account_async.py b/packages/google-auth/tests_async/oauth2/test_service_account_async.py new file mode 100644 index 000000000000..176992f7714f --- /dev/null +++ b/packages/google-auth/tests_async/oauth2/test_service_account_async.py @@ -0,0 +1,378 @@ +# 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 datetime + +import mock +import pytest # type: ignore + +from google.auth import _helpers +from google.auth import crypt +from google.auth import jwt +from google.auth import transport +from google.oauth2 import _service_account_async as service_account +from tests.oauth2 import test_service_account + + +class TestCredentials(object): + SERVICE_ACCOUNT_EMAIL = "service-account@example.com" + TOKEN_URI = "https://example.com/oauth2/token" + + @classmethod + def make_credentials(cls): + return service_account.Credentials( + test_service_account.SIGNER, cls.SERVICE_ACCOUNT_EMAIL, cls.TOKEN_URI + ) + + def test_from_service_account_info(self): + credentials = service_account.Credentials.from_service_account_info( + test_service_account.SERVICE_ACCOUNT_INFO + ) + + assert ( + credentials._signer.key_id + == test_service_account.SERVICE_ACCOUNT_INFO["private_key_id"] + ) + assert ( + credentials.service_account_email + == test_service_account.SERVICE_ACCOUNT_INFO["client_email"] + ) + assert ( + credentials._token_uri + == test_service_account.SERVICE_ACCOUNT_INFO["token_uri"] + ) + + def test_from_service_account_info_args(self): + info = test_service_account.SERVICE_ACCOUNT_INFO.copy() + scopes = ["email", "profile"] + subject = "subject" + additional_claims = {"meta": "data"} + + credentials = service_account.Credentials.from_service_account_info( + info, scopes=scopes, subject=subject, additional_claims=additional_claims + ) + + assert credentials.service_account_email == info["client_email"] + assert credentials.project_id == info["project_id"] + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._token_uri == info["token_uri"] + assert credentials._scopes == scopes + assert credentials._subject == subject + assert credentials._additional_claims == additional_claims + + def test_from_service_account_file(self): + info = test_service_account.SERVICE_ACCOUNT_INFO.copy() + + credentials = service_account.Credentials.from_service_account_file( + test_service_account.SERVICE_ACCOUNT_JSON_FILE + ) + + assert credentials.service_account_email == info["client_email"] + assert credentials.project_id == info["project_id"] + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._token_uri == info["token_uri"] + + def test_from_service_account_file_args(self): + info = test_service_account.SERVICE_ACCOUNT_INFO.copy() + scopes = ["email", "profile"] + subject = "subject" + additional_claims = {"meta": "data"} + + credentials = service_account.Credentials.from_service_account_file( + test_service_account.SERVICE_ACCOUNT_JSON_FILE, + subject=subject, + scopes=scopes, + additional_claims=additional_claims, + ) + + assert credentials.service_account_email == info["client_email"] + assert credentials.project_id == info["project_id"] + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._token_uri == info["token_uri"] + assert credentials._scopes == scopes + assert credentials._subject == subject + assert credentials._additional_claims == additional_claims + + def test_default_state(self): + credentials = self.make_credentials() + assert not credentials.valid + # Expiration hasn't been set yet + assert not credentials.expired + # Scopes haven't been specified yet + assert credentials.requires_scopes + + def test_sign_bytes(self): + credentials = self.make_credentials() + to_sign = b"123" + signature = credentials.sign_bytes(to_sign) + assert crypt.verify_signature( + to_sign, signature, test_service_account.PUBLIC_CERT_BYTES + ) + + def test_signer(self): + credentials = self.make_credentials() + assert isinstance(credentials.signer, crypt.Signer) + + def test_signer_email(self): + credentials = self.make_credentials() + assert credentials.signer_email == self.SERVICE_ACCOUNT_EMAIL + + def test_create_scoped(self): + credentials = self.make_credentials() + scopes = ["email", "profile"] + credentials = credentials.with_scopes(scopes) + assert credentials._scopes == scopes + + def test_with_claims(self): + credentials = self.make_credentials() + new_credentials = credentials.with_claims({"meep": "moop"}) + assert new_credentials._additional_claims == {"meep": "moop"} + + def test_with_quota_project(self): + credentials = self.make_credentials() + new_credentials = credentials.with_quota_project("new-project-456") + assert new_credentials.quota_project_id == "new-project-456" + hdrs = {} + new_credentials.apply(hdrs, token="tok") + assert "x-goog-user-project" in hdrs + + def test__make_authorization_grant_assertion(self): + credentials = self.make_credentials() + 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 + ) + + def test__make_authorization_grant_assertion_scoped(self): + credentials = self.make_credentials() + scopes = ["email", "profile"] + credentials = credentials.with_scopes(scopes) + token = credentials._make_authorization_grant_assertion() + payload = jwt.decode(token, test_service_account.PUBLIC_CERT_BYTES) + assert payload["scope"] == "email profile" + + def test__make_authorization_grant_assertion_subject(self): + credentials = self.make_credentials() + subject = "user@example.com" + credentials = credentials.with_subject(subject) + token = credentials._make_authorization_grant_assertion() + payload = jwt.decode(token, test_service_account.PUBLIC_CERT_BYTES) + assert payload["sub"] == subject + + @mock.patch("google.oauth2._client_async.jwt_grant", autospec=True) + @pytest.mark.asyncio + async def test_refresh_success(self, jwt_grant): + credentials = self.make_credentials() + token = "token" + jwt_grant.return_value = ( + token, + _helpers.utcnow() + datetime.timedelta(seconds=500), + {}, + ) + request = mock.create_autospec(transport.Request, instance=True) + + # Refresh credentials + await credentials.refresh(request) + + # Check jwt grant call. + assert jwt_grant.called + + called_request, token_uri, assertion = jwt_grant.call_args[0] + assert called_request == request + assert token_uri == credentials._token_uri + assert jwt.decode(assertion, test_service_account.PUBLIC_CERT_BYTES) + # No further assertion done on the token, as there are separate tests + # for checking the authorization grant assertion. + + # Check that the credentials have the token. + assert credentials.token == token + + # Check that the credentials are valid (have a token and are not + # expired) + assert credentials.valid + + @mock.patch("google.oauth2._client_async.jwt_grant", autospec=True) + @pytest.mark.asyncio + async def test_before_request_refreshes(self, jwt_grant): + credentials = self.make_credentials() + token = "token" + jwt_grant.return_value = ( + token, + _helpers.utcnow() + datetime.timedelta(seconds=500), + None, + ) + request = mock.create_autospec(transport.Request, instance=True) + + # Credentials should start as invalid + assert not credentials.valid + + # before_request should cause a refresh + await credentials.before_request(request, "GET", "http://example.com?a=1#3", {}) + + # The refresh endpoint should've been called. + assert jwt_grant.called + + # Credentials should now be valid. + assert credentials.valid + + +class TestIDTokenCredentials(object): + SERVICE_ACCOUNT_EMAIL = "service-account@example.com" + TOKEN_URI = "https://example.com/oauth2/token" + TARGET_AUDIENCE = "https://example.com" + + @classmethod + def make_credentials(cls): + return service_account.IDTokenCredentials( + test_service_account.SIGNER, + cls.SERVICE_ACCOUNT_EMAIL, + cls.TOKEN_URI, + cls.TARGET_AUDIENCE, + ) + + def test_from_service_account_info(self): + credentials = service_account.IDTokenCredentials.from_service_account_info( + test_service_account.SERVICE_ACCOUNT_INFO, + target_audience=self.TARGET_AUDIENCE, + ) + + assert ( + credentials._signer.key_id + == test_service_account.SERVICE_ACCOUNT_INFO["private_key_id"] + ) + assert ( + credentials.service_account_email + == test_service_account.SERVICE_ACCOUNT_INFO["client_email"] + ) + assert ( + credentials._token_uri + == test_service_account.SERVICE_ACCOUNT_INFO["token_uri"] + ) + assert credentials._target_audience == self.TARGET_AUDIENCE + + def test_from_service_account_file(self): + info = test_service_account.SERVICE_ACCOUNT_INFO.copy() + + credentials = service_account.IDTokenCredentials.from_service_account_file( + test_service_account.SERVICE_ACCOUNT_JSON_FILE, + target_audience=self.TARGET_AUDIENCE, + ) + + assert credentials.service_account_email == info["client_email"] + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._token_uri == info["token_uri"] + assert credentials._target_audience == self.TARGET_AUDIENCE + + def test_default_state(self): + credentials = self.make_credentials() + assert not credentials.valid + # Expiration hasn't been set yet + assert not credentials.expired + + def test_sign_bytes(self): + credentials = self.make_credentials() + to_sign = b"123" + signature = credentials.sign_bytes(to_sign) + assert crypt.verify_signature( + to_sign, signature, test_service_account.PUBLIC_CERT_BYTES + ) + + def test_signer(self): + credentials = self.make_credentials() + assert isinstance(credentials.signer, crypt.Signer) + + def test_signer_email(self): + credentials = self.make_credentials() + assert credentials.signer_email == self.SERVICE_ACCOUNT_EMAIL + + def test_with_target_audience(self): + credentials = self.make_credentials() + new_credentials = credentials.with_target_audience("https://new.example.com") + assert new_credentials._target_audience == "https://new.example.com" + + def test_with_quota_project(self): + credentials = self.make_credentials() + new_credentials = credentials.with_quota_project("project-foo") + assert new_credentials._quota_project_id == "project-foo" + + def test__make_authorization_grant_assertion(self): + credentials = self.make_credentials() + 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["target_audience"] == self.TARGET_AUDIENCE + + @mock.patch("google.oauth2._client_async.id_token_jwt_grant", autospec=True) + @pytest.mark.asyncio + async def test_refresh_success(self, id_token_jwt_grant): + credentials = self.make_credentials() + token = "token" + id_token_jwt_grant.return_value = ( + token, + _helpers.utcnow() + datetime.timedelta(seconds=500), + {}, + ) + + request = mock.AsyncMock(spec=["transport.Request"]) + + # Refresh credentials + await credentials.refresh(request) + + # Check jwt grant call. + assert id_token_jwt_grant.called + + called_request, token_uri, assertion = id_token_jwt_grant.call_args[0] + assert called_request == request + assert token_uri == credentials._token_uri + assert jwt.decode(assertion, test_service_account.PUBLIC_CERT_BYTES) + # No further assertion done on the token, as there are separate tests + # for checking the authorization grant assertion. + + # Check that the credentials have the token. + assert credentials.token == token + + # Check that the credentials are valid (have a token and are not + # expired) + assert credentials.valid + + @mock.patch("google.oauth2._client_async.id_token_jwt_grant", autospec=True) + @pytest.mark.asyncio + async def test_before_request_refreshes(self, id_token_jwt_grant): + credentials = self.make_credentials() + token = "token" + id_token_jwt_grant.return_value = ( + token, + _helpers.utcnow() + datetime.timedelta(seconds=500), + None, + ) + request = mock.AsyncMock(spec=["transport.Request"]) + + # Credentials should start as invalid + assert not credentials.valid + + # before_request should cause a refresh + await credentials.before_request(request, "GET", "http://example.com?a=1#3", {}) + + # The refresh endpoint should've been called. + assert id_token_jwt_grant.called + + # Credentials should now be valid. + assert credentials.valid diff --git a/packages/google-auth/tests_async/test__default_async.py b/packages/google-auth/tests_async/test__default_async.py new file mode 100644 index 000000000000..e1dbb1c09c20 --- /dev/null +++ b/packages/google-auth/tests_async/test__default_async.py @@ -0,0 +1,563 @@ +# 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 json +import os + +import mock +import pytest # type: ignore + +from google.auth import _credentials_async as credentials +from google.auth import _default_async as _default +from google.auth import app_engine +from google.auth import compute_engine +from google.auth import environment_vars +from google.auth import exceptions +from google.oauth2 import _service_account_async as service_account +import google.oauth2.credentials +from tests import test__default as test_default + +MOCK_CREDENTIALS = mock.Mock(spec=credentials.CredentialsWithQuotaProject) +MOCK_CREDENTIALS.with_quota_project.return_value = MOCK_CREDENTIALS + +LOAD_FILE_PATCH = mock.patch( + "google.auth._default_async.load_credentials_from_file", + return_value=(MOCK_CREDENTIALS, mock.sentinel.project_id), + autospec=True, +) + + +def test_load_credentials_from_missing_file(): + with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: + _default.load_credentials_from_file("") + + assert excinfo.match(r"not found") + + +def test_load_credentials_from_file_invalid_json(tmpdir): + jsonfile = tmpdir.join("invalid.json") + jsonfile.write("{") + + with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: + _default.load_credentials_from_file(str(jsonfile)) + + assert excinfo.match(r"not a valid json file") + + +def test_load_credentials_from_file_invalid_type(tmpdir): + jsonfile = tmpdir.join("invalid.json") + jsonfile.write(json.dumps({"type": "not-a-real-type"})) + + with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: + _default.load_credentials_from_file(str(jsonfile)) + + assert excinfo.match(r"does not have a valid type") + + +def test_load_credentials_from_file_authorized_user(): + credentials, project_id = _default.load_credentials_from_file( + test_default.AUTHORIZED_USER_FILE + ) + assert isinstance(credentials, google.oauth2._credentials_async.Credentials) + assert project_id is None + + +def test_load_credentials_from_file_no_type(tmpdir): + # use the client_secrets.json, which is valid json but not a + # loadable credentials type + with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: + _default.load_credentials_from_file(test_default.CLIENT_SECRETS_FILE) + + assert excinfo.match(r"does not have a valid type") + assert excinfo.match(r"Type is None") + + +def test_load_credentials_from_file_authorized_user_bad_format(tmpdir): + filename = tmpdir.join("authorized_user_bad.json") + filename.write(json.dumps({"type": "authorized_user"})) + + with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: + _default.load_credentials_from_file(str(filename)) + + assert excinfo.match(r"Failed to load authorized user") + assert excinfo.match(r"missing fields") + + +def test_load_credentials_from_file_authorized_user_cloud_sdk(): + with pytest.warns(UserWarning, match="Cloud SDK"): + credentials, project_id = _default.load_credentials_from_file( + test_default.AUTHORIZED_USER_CLOUD_SDK_FILE + ) + assert isinstance(credentials, google.oauth2._credentials_async.Credentials) + assert project_id is None + + # No warning if the json file has quota project id. + credentials, project_id = _default.load_credentials_from_file( + test_default.AUTHORIZED_USER_CLOUD_SDK_WITH_QUOTA_PROJECT_ID_FILE + ) + assert isinstance(credentials, google.oauth2._credentials_async.Credentials) + assert project_id is None + + +def test_load_credentials_from_file_authorized_user_cloud_sdk_with_scopes(): + with pytest.warns(UserWarning, match="Cloud SDK"): + credentials, project_id = _default.load_credentials_from_file( + test_default.AUTHORIZED_USER_CLOUD_SDK_FILE, + scopes=["https://www.google.com/calendar/feeds"], + ) + assert isinstance(credentials, google.oauth2._credentials_async.Credentials) + assert project_id is None + assert credentials.scopes == ["https://www.google.com/calendar/feeds"] + + +def test_load_credentials_from_file_authorized_user_cloud_sdk_with_quota_project(): + credentials, project_id = _default.load_credentials_from_file( + test_default.AUTHORIZED_USER_CLOUD_SDK_FILE, quota_project_id="project-foo" + ) + + assert isinstance(credentials, google.oauth2._credentials_async.Credentials) + assert project_id is None + assert credentials.quota_project_id == "project-foo" + + +def test_load_credentials_from_file_service_account(): + credentials, project_id = _default.load_credentials_from_file( + test_default.SERVICE_ACCOUNT_FILE + ) + assert isinstance(credentials, service_account.Credentials) + assert project_id == test_default.SERVICE_ACCOUNT_FILE_DATA["project_id"] + + +def test_load_credentials_from_file_service_account_with_scopes(): + credentials, project_id = _default.load_credentials_from_file( + test_default.SERVICE_ACCOUNT_FILE, + scopes=["https://www.google.com/calendar/feeds"], + ) + assert isinstance(credentials, service_account.Credentials) + assert project_id == test_default.SERVICE_ACCOUNT_FILE_DATA["project_id"] + assert credentials.scopes == ["https://www.google.com/calendar/feeds"] + + +def test_load_credentials_from_file_service_account_bad_format(tmpdir): + filename = tmpdir.join("serivce_account_bad.json") + filename.write(json.dumps({"type": "service_account"})) + + with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: + _default.load_credentials_from_file(str(filename)) + + assert excinfo.match(r"Failed to load service account") + assert excinfo.match(r"missing fields") + + +@mock.patch.dict(os.environ, {}, clear=True) +def test__get_explicit_environ_credentials_no_env(): + assert _default._get_explicit_environ_credentials() == (None, None) + + +@pytest.mark.parametrize("quota_project_id", [None, "project-foo"]) +@LOAD_FILE_PATCH +def test__get_explicit_environ_credentials(load, quota_project_id, monkeypatch): + monkeypatch.setenv(environment_vars.CREDENTIALS, "filename") + + credentials, project_id = _default._get_explicit_environ_credentials( + quota_project_id=quota_project_id + ) + + assert credentials is MOCK_CREDENTIALS + assert project_id is mock.sentinel.project_id + load.assert_called_with("filename", quota_project_id=quota_project_id) + + +@LOAD_FILE_PATCH +def test__get_explicit_environ_credentials_no_project_id(load, monkeypatch): + load.return_value = MOCK_CREDENTIALS, None + monkeypatch.setenv(environment_vars.CREDENTIALS, "filename") + + credentials, project_id = _default._get_explicit_environ_credentials() + + assert credentials is MOCK_CREDENTIALS + assert project_id is None + + +@pytest.mark.parametrize("quota_project_id", [None, "project-foo"]) +@mock.patch( + "google.auth._cloud_sdk.get_application_default_credentials_path", autospec=True +) +@mock.patch("google.auth._default_async._get_gcloud_sdk_credentials", autospec=True) +def test__get_explicit_environ_credentials_fallback_to_gcloud( + get_gcloud_creds, get_adc_path, quota_project_id, monkeypatch +): + # Set explicit credentials path to cloud sdk credentials path. + get_adc_path.return_value = "filename" + monkeypatch.setenv(environment_vars.CREDENTIALS, "filename") + + _default._get_explicit_environ_credentials(quota_project_id=quota_project_id) + + # Check we fall back to cloud sdk flow since explicit credentials path is + # cloud sdk credentials path + get_gcloud_creds.assert_called_with(quota_project_id=quota_project_id) + + +@pytest.mark.parametrize("quota_project_id", [None, "project-foo"]) +@LOAD_FILE_PATCH +@mock.patch( + "google.auth._cloud_sdk.get_application_default_credentials_path", autospec=True +) +def test__get_gcloud_sdk_credentials(get_adc_path, load, quota_project_id): + get_adc_path.return_value = test_default.SERVICE_ACCOUNT_FILE + + credentials, project_id = _default._get_gcloud_sdk_credentials( + quota_project_id=quota_project_id + ) + + assert credentials is MOCK_CREDENTIALS + assert project_id is mock.sentinel.project_id + load.assert_called_with( + test_default.SERVICE_ACCOUNT_FILE, quota_project_id=quota_project_id + ) + + +@mock.patch( + "google.auth._cloud_sdk.get_application_default_credentials_path", autospec=True +) +def test__get_gcloud_sdk_credentials_non_existent(get_adc_path, tmpdir): + non_existent = tmpdir.join("non-existent") + get_adc_path.return_value = str(non_existent) + + credentials, project_id = _default._get_gcloud_sdk_credentials() + + assert credentials is None + assert project_id is None + + +@mock.patch( + "google.auth._cloud_sdk.get_project_id", + return_value=mock.sentinel.project_id, + autospec=True, +) +@mock.patch("os.path.isfile", return_value=True, autospec=True) +@LOAD_FILE_PATCH +def test__get_gcloud_sdk_credentials_project_id(load, unused_isfile, get_project_id): + # Don't return a project ID from load file, make the function check + # the Cloud SDK project. + load.return_value = MOCK_CREDENTIALS, None + + credentials, project_id = _default._get_gcloud_sdk_credentials() + + assert credentials == MOCK_CREDENTIALS + assert project_id == mock.sentinel.project_id + assert get_project_id.called + + +@mock.patch("google.auth._cloud_sdk.get_project_id", return_value=None, autospec=True) +@mock.patch("os.path.isfile", return_value=True) +@LOAD_FILE_PATCH +def test__get_gcloud_sdk_credentials_no_project_id(load, unused_isfile, get_project_id): + # Don't return a project ID from load file, make the function check + # the Cloud SDK project. + load.return_value = MOCK_CREDENTIALS, None + + credentials, project_id = _default._get_gcloud_sdk_credentials() + + assert credentials == MOCK_CREDENTIALS + assert project_id is None + assert get_project_id.called + + +class _AppIdentityModule(object): + """The interface of the App Idenity app engine module. + See https://cloud.google.com/appengine/docs/standard/python/refdocs\ + /google.appengine.api.app_identity.app_identity + """ + + def get_application_id(self): + raise NotImplementedError() + + +@pytest.fixture +def app_identity(monkeypatch): + """Mocks the app_identity module for google.auth.app_engine.""" + app_identity_module = mock.create_autospec(_AppIdentityModule, instance=True) + monkeypatch.setattr(app_engine, "app_identity", app_identity_module) + yield app_identity_module + + +@mock.patch.dict(os.environ) +def test__get_gae_credentials_gen1(app_identity): + os.environ[environment_vars.LEGACY_APPENGINE_RUNTIME] = "python27" + app_identity.get_application_id.return_value = mock.sentinel.project + + credentials, project_id = _default._get_gae_credentials() + + assert isinstance(credentials, app_engine.Credentials) + assert project_id == mock.sentinel.project + + +@mock.patch.dict(os.environ) +def test__get_gae_credentials_gen2(): + os.environ["GAE_RUNTIME"] = "python37" + credentials, project_id = _default._get_gae_credentials() + assert credentials is None + assert project_id is None + + +@mock.patch.dict(os.environ) +def test__get_gae_credentials_gen2_backwards_compat(): + # compat helpers may copy GAE_RUNTIME to APPENGINE_RUNTIME + # for backwards compatibility with code that relies on it + os.environ[environment_vars.LEGACY_APPENGINE_RUNTIME] = "python37" + os.environ["GAE_RUNTIME"] = "python37" + credentials, project_id = _default._get_gae_credentials() + assert credentials is None + assert project_id is None + + +def test__get_gae_credentials_env_unset(): + assert environment_vars.LEGACY_APPENGINE_RUNTIME not in os.environ + assert "GAE_RUNTIME" not in os.environ + credentials, project_id = _default._get_gae_credentials() + assert credentials is None + assert project_id is None + + +@mock.patch.dict(os.environ) +def test__get_gae_credentials_no_app_engine(): + # test both with and without LEGACY_APPENGINE_RUNTIME setting + assert environment_vars.LEGACY_APPENGINE_RUNTIME not in os.environ + + import sys + + with mock.patch.dict(sys.modules, {"google.auth.app_engine": None}): + credentials, project_id = _default._get_gae_credentials() + assert credentials is None + assert project_id is None + + os.environ[environment_vars.LEGACY_APPENGINE_RUNTIME] = "python27" + credentials, project_id = _default._get_gae_credentials() + assert credentials is None + assert project_id is None + + +@mock.patch.dict(os.environ) +@mock.patch.object(app_engine, "app_identity", new=None) +def test__get_gae_credentials_no_apis(): + # test both with and without LEGACY_APPENGINE_RUNTIME setting + assert environment_vars.LEGACY_APPENGINE_RUNTIME not in os.environ + + credentials, project_id = _default._get_gae_credentials() + assert credentials is None + assert project_id is None + + os.environ[environment_vars.LEGACY_APPENGINE_RUNTIME] = "python27" + credentials, project_id = _default._get_gae_credentials() + assert credentials is None + assert project_id is None + + +@mock.patch( + "google.auth.compute_engine._metadata.is_on_gce", return_value=True, autospec=True +) +@mock.patch( + "google.auth.compute_engine._metadata.get_project_id", + return_value="example-project", + autospec=True, +) +def test__get_gce_credentials(unused_get, unused_ping): + credentials, project_id = _default._get_gce_credentials() + + assert isinstance(credentials, compute_engine.Credentials) + assert project_id == "example-project" + + +@mock.patch( + "google.auth.compute_engine._metadata.is_on_gce", return_value=False, autospec=True +) +def test__get_gce_credentials_no_ping(unused_ping): + credentials, project_id = _default._get_gce_credentials() + + assert credentials is None + assert project_id is None + + +@mock.patch( + "google.auth.compute_engine._metadata.is_on_gce", return_value=True, autospec=True +) +@mock.patch( + "google.auth.compute_engine._metadata.get_project_id", + side_effect=exceptions.TransportError(), + autospec=True, +) +def test__get_gce_credentials_no_project_id(unused_get, unused_ping): + credentials, project_id = _default._get_gce_credentials() + + assert isinstance(credentials, compute_engine.Credentials) + assert project_id is None + + +def test__get_gce_credentials_no_compute_engine(): + import sys + + with mock.patch.dict("sys.modules"): + sys.modules["google.auth.compute_engine"] = None + credentials, project_id = _default._get_gce_credentials() + assert credentials is None + assert project_id is None + + +@mock.patch( + "google.auth.compute_engine._metadata.is_on_gce", return_value=False, autospec=True +) +def test__get_gce_credentials_explicit_request(ping): + _default._get_gce_credentials(mock.sentinel.request) + ping.assert_called_with(request=mock.sentinel.request) + + +@mock.patch( + "google.auth._default_async._get_explicit_environ_credentials", + return_value=(MOCK_CREDENTIALS, mock.sentinel.project_id), + autospec=True, +) +def test_default_early_out(unused_get): + assert _default.default_async() == (MOCK_CREDENTIALS, mock.sentinel.project_id) + + +@mock.patch( + "google.auth._default_async._get_explicit_environ_credentials", + return_value=(MOCK_CREDENTIALS, mock.sentinel.project_id), + autospec=True, +) +def test_default_explict_project_id(unused_get, monkeypatch): + monkeypatch.setenv(environment_vars.PROJECT, "explicit-env") + assert _default.default_async() == (MOCK_CREDENTIALS, "explicit-env") + + +@mock.patch( + "google.auth._default_async._get_explicit_environ_credentials", + return_value=(MOCK_CREDENTIALS, mock.sentinel.project_id), + autospec=True, +) +def test_default_explict_legacy_project_id(unused_get, monkeypatch): + monkeypatch.setenv(environment_vars.LEGACY_PROJECT, "explicit-env") + assert _default.default_async() == (MOCK_CREDENTIALS, "explicit-env") + + +@mock.patch("logging.Logger.warning", autospec=True) +@mock.patch( + "google.auth._default_async._get_explicit_environ_credentials", + return_value=(MOCK_CREDENTIALS, None), + autospec=True, +) +@mock.patch( + "google.auth._default_async._get_gcloud_sdk_credentials", + return_value=(MOCK_CREDENTIALS, None), + autospec=True, +) +@mock.patch( + "google.auth._default_async._get_gae_credentials", + return_value=(MOCK_CREDENTIALS, None), + autospec=True, +) +@mock.patch( + "google.auth._default_async._get_gce_credentials", + return_value=(MOCK_CREDENTIALS, None), + autospec=True, +) +def test_default_without_project_id( + unused_gce, unused_gae, unused_sdk, unused_explicit, logger_warning +): + assert _default.default_async() == (MOCK_CREDENTIALS, None) + logger_warning.assert_called_with(mock.ANY, mock.ANY, mock.ANY) + + +@mock.patch( + "google.auth._default_async._get_explicit_environ_credentials", + return_value=(None, None), + autospec=True, +) +@mock.patch( + "google.auth._default_async._get_gcloud_sdk_credentials", + return_value=(None, None), + autospec=True, +) +@mock.patch( + "google.auth._default_async._get_gae_credentials", + return_value=(None, None), + autospec=True, +) +@mock.patch( + "google.auth._default_async._get_gce_credentials", + return_value=(None, None), + autospec=True, +) +def test_default_fail(unused_gce, unused_gae, unused_sdk, unused_explicit): + with pytest.raises(exceptions.DefaultCredentialsError): + assert _default.default_async() + + +@mock.patch( + "google.auth._default_async._get_explicit_environ_credentials", + return_value=(MOCK_CREDENTIALS, mock.sentinel.project_id), + autospec=True, +) +@mock.patch( + "google.auth._credentials_async.with_scopes_if_required", + return_value=MOCK_CREDENTIALS, + autospec=True, +) +def test_default_scoped(with_scopes, unused_get): + scopes = ["one", "two"] + + credentials, project_id = _default.default_async(scopes=scopes) + + assert credentials == with_scopes.return_value + assert project_id == mock.sentinel.project_id + with_scopes.assert_called_once_with(MOCK_CREDENTIALS, scopes) + + +@mock.patch( + "google.auth._default_async._get_explicit_environ_credentials", + return_value=(MOCK_CREDENTIALS, mock.sentinel.project_id), + autospec=True, +) +def test_default_no_app_engine_compute_engine_module(unused_get): + """ + google.auth.compute_engine and google.auth.app_engine are both optional + to allow not including them when using this package. This verifies + that default fails gracefully if these modules are absent + """ + import sys + + with mock.patch.dict("sys.modules"): + sys.modules["google.auth.compute_engine"] = None + sys.modules["google.auth.app_engine"] = None + assert _default.default_async() == (MOCK_CREDENTIALS, mock.sentinel.project_id) + + +@mock.patch( + "google.auth._cloud_sdk.get_application_default_credentials_path", autospec=True +) +def test_default_warning_without_quota_project_id_for_user_creds(get_adc_path): + get_adc_path.return_value = test_default.AUTHORIZED_USER_CLOUD_SDK_FILE + + with pytest.warns(UserWarning, match="Cloud SDK"): + credentials, project_id = _default.default_async(quota_project_id=None) + + +@mock.patch( + "google.auth._cloud_sdk.get_application_default_credentials_path", autospec=True +) +def test_default_no_warning_with_quota_project_id_for_user_creds(get_adc_path): + get_adc_path.return_value = test_default.AUTHORIZED_USER_CLOUD_SDK_FILE + + credentials, project_id = _default.default_async(quota_project_id="project-foo") diff --git a/packages/google-auth/tests_async/test_credentials_async.py b/packages/google-auth/tests_async/test_credentials_async.py new file mode 100644 index 000000000000..7d82758c38b6 --- /dev/null +++ b/packages/google-auth/tests_async/test_credentials_async.py @@ -0,0 +1,177 @@ +# 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 datetime + +import pytest # type: ignore + +from google.auth import _credentials_async as credentials +from google.auth import _helpers + + +class CredentialsImpl(credentials.Credentials): + def refresh(self, request): + self.token = request + + def with_quota_project(self, quota_project_id): + raise NotImplementedError() + + +def test_credentials_constructor(): + credentials = CredentialsImpl() + assert not credentials.token + assert not credentials.expiry + assert not credentials.expired + assert not credentials.valid + + +def test_expired_and_valid(): + credentials = CredentialsImpl() + credentials.token = "token" + + assert credentials.valid + assert not credentials.expired + + # Set the expiration to one second more than now plus the clock skew + # accomodation. These credentials should be valid. + credentials.expiry = ( + _helpers.utcnow() + _helpers.REFRESH_THRESHOLD + datetime.timedelta(seconds=1) + ) + + assert credentials.valid + assert not credentials.expired + + # Set the credentials expiration to now. Because of the clock skew + # accomodation, these credentials should report as expired. + credentials.expiry = _helpers.utcnow() + + assert not credentials.valid + assert credentials.expired + + +@pytest.mark.asyncio +async def test_before_request(): + credentials = CredentialsImpl() + request = "token" + headers = {} + + # First call should call refresh, setting the token. + await credentials.before_request(request, "http://example.com", "GET", headers) + assert credentials.valid + assert credentials.token == "token" + assert headers["authorization"] == "Bearer token" + + request = "token2" + headers = {} + + # Second call shouldn't call refresh. + credentials.before_request(request, "http://example.com", "GET", headers) + + assert credentials.valid + assert credentials.token == "token" + + +def test_anonymous_credentials_ctor(): + anon = credentials.AnonymousCredentials() + + assert anon.token is None + assert anon.expiry is None + assert not anon.expired + assert anon.valid + + +def test_anonymous_credentials_refresh(): + anon = credentials.AnonymousCredentials() + + request = object() + with pytest.raises(ValueError): + anon.refresh(request) + + +def test_anonymous_credentials_apply_default(): + anon = credentials.AnonymousCredentials() + headers = {} + anon.apply(headers) + assert headers == {} + with pytest.raises(ValueError): + anon.apply(headers, token="TOKEN") + + +def test_anonymous_credentials_before_request(): + anon = credentials.AnonymousCredentials() + request = object() + method = "GET" + url = "https://example.com/api/endpoint" + headers = {} + anon.before_request(request, method, url, headers) + assert headers == {} + + +class ReadOnlyScopedCredentialsImpl(credentials.ReadOnlyScoped, CredentialsImpl): + @property + def requires_scopes(self): + return super(ReadOnlyScopedCredentialsImpl, self).requires_scopes + + +def test_readonly_scoped_credentials_constructor(): + credentials = ReadOnlyScopedCredentialsImpl() + assert credentials._scopes is None + + +def test_readonly_scoped_credentials_scopes(): + credentials = ReadOnlyScopedCredentialsImpl() + credentials._scopes = ["one", "two"] + assert credentials.scopes == ["one", "two"] + assert credentials.has_scopes(["one"]) + assert credentials.has_scopes(["two"]) + assert credentials.has_scopes(["one", "two"]) + assert not credentials.has_scopes(["three"]) + + +def test_readonly_scoped_credentials_requires_scopes(): + credentials = ReadOnlyScopedCredentialsImpl() + assert not credentials.requires_scopes + + +class RequiresScopedCredentialsImpl(credentials.Scoped, CredentialsImpl): + def __init__(self, scopes=None): + super(RequiresScopedCredentialsImpl, self).__init__() + self._scopes = scopes + + @property + def requires_scopes(self): + return not self.scopes + + def with_scopes(self, scopes): + return RequiresScopedCredentialsImpl(scopes=scopes) + + +def test_create_scoped_if_required_scoped(): + unscoped_credentials = RequiresScopedCredentialsImpl() + scoped_credentials = credentials.with_scopes_if_required( + unscoped_credentials, ["one", "two"] + ) + + assert scoped_credentials is not unscoped_credentials + assert not scoped_credentials.requires_scopes + assert scoped_credentials.has_scopes(["one", "two"]) + + +def test_create_scoped_if_required_not_scopes(): + unscoped_credentials = CredentialsImpl() + scoped_credentials = credentials.with_scopes_if_required( + unscoped_credentials, ["one", "two"] + ) + + assert scoped_credentials is unscoped_credentials diff --git a/packages/google-auth/tests_async/test_jwt_async.py b/packages/google-auth/tests_async/test_jwt_async.py new file mode 100644 index 000000000000..f24a0a99d224 --- /dev/null +++ b/packages/google-auth/tests_async/test_jwt_async.py @@ -0,0 +1,356 @@ +# Copyright 2020 Google Inc. +# +# 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 datetime +import json + +import mock +import pytest # type: ignore + +from google.auth import _jwt_async as jwt_async +from google.auth import crypt +from google.auth import exceptions +from tests import test_jwt + + +@pytest.fixture +def signer(): + return crypt.RSASigner.from_string(test_jwt.PRIVATE_KEY_BYTES, "1") + + +class TestCredentials(object): + SERVICE_ACCOUNT_EMAIL = "service-account@example.com" + SUBJECT = "subject" + AUDIENCE = "audience" + ADDITIONAL_CLAIMS = {"meta": "data"} + credentials = None + + @pytest.fixture(autouse=True) + def credentials_fixture(self, signer): + self.credentials = jwt_async.Credentials( + signer, + self.SERVICE_ACCOUNT_EMAIL, + self.SERVICE_ACCOUNT_EMAIL, + self.AUDIENCE, + ) + + def test_from_service_account_info(self): + with open(test_jwt.SERVICE_ACCOUNT_JSON_FILE, "r") as fh: + info = json.load(fh) + + credentials = jwt_async.Credentials.from_service_account_info( + info, audience=self.AUDIENCE + ) + + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._issuer == info["client_email"] + assert credentials._subject == info["client_email"] + assert credentials._audience == self.AUDIENCE + + def test_from_service_account_info_args(self): + info = test_jwt.SERVICE_ACCOUNT_INFO.copy() + + credentials = jwt_async.Credentials.from_service_account_info( + info, + subject=self.SUBJECT, + audience=self.AUDIENCE, + additional_claims=self.ADDITIONAL_CLAIMS, + ) + + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._issuer == info["client_email"] + assert credentials._subject == self.SUBJECT + assert credentials._audience == self.AUDIENCE + assert credentials._additional_claims == self.ADDITIONAL_CLAIMS + + def test_from_service_account_file(self): + info = test_jwt.SERVICE_ACCOUNT_INFO.copy() + + credentials = jwt_async.Credentials.from_service_account_file( + test_jwt.SERVICE_ACCOUNT_JSON_FILE, audience=self.AUDIENCE + ) + + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._issuer == info["client_email"] + assert credentials._subject == info["client_email"] + assert credentials._audience == self.AUDIENCE + + def test_from_service_account_file_args(self): + info = test_jwt.SERVICE_ACCOUNT_INFO.copy() + + credentials = jwt_async.Credentials.from_service_account_file( + test_jwt.SERVICE_ACCOUNT_JSON_FILE, + subject=self.SUBJECT, + audience=self.AUDIENCE, + additional_claims=self.ADDITIONAL_CLAIMS, + ) + + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._issuer == info["client_email"] + assert credentials._subject == self.SUBJECT + assert credentials._audience == self.AUDIENCE + assert credentials._additional_claims == self.ADDITIONAL_CLAIMS + + def test_from_signing_credentials(self): + jwt_from_signing = self.credentials.from_signing_credentials( + self.credentials, audience=mock.sentinel.new_audience + ) + jwt_from_info = jwt_async.Credentials.from_service_account_info( + test_jwt.SERVICE_ACCOUNT_INFO, audience=mock.sentinel.new_audience + ) + + assert isinstance(jwt_from_signing, jwt_async.Credentials) + assert jwt_from_signing._signer.key_id == jwt_from_info._signer.key_id + assert jwt_from_signing._issuer == jwt_from_info._issuer + assert jwt_from_signing._subject == jwt_from_info._subject + assert jwt_from_signing._audience == jwt_from_info._audience + + def test_default_state(self): + assert not self.credentials.valid + # Expiration hasn't been set yet + assert not self.credentials.expired + + def test_with_claims(self): + new_audience = "new_audience" + new_credentials = self.credentials.with_claims(audience=new_audience) + + assert new_credentials._signer == self.credentials._signer + assert new_credentials._issuer == self.credentials._issuer + assert new_credentials._subject == self.credentials._subject + assert new_credentials._audience == new_audience + assert new_credentials._additional_claims == self.credentials._additional_claims + assert new_credentials._quota_project_id == self.credentials._quota_project_id + + def test_with_quota_project(self): + quota_project_id = "project-foo" + + new_credentials = self.credentials.with_quota_project(quota_project_id) + assert new_credentials._signer == self.credentials._signer + assert new_credentials._issuer == self.credentials._issuer + assert new_credentials._subject == self.credentials._subject + assert new_credentials._audience == self.credentials._audience + assert new_credentials._additional_claims == self.credentials._additional_claims + assert new_credentials._quota_project_id == quota_project_id + + def test_sign_bytes(self): + to_sign = b"123" + signature = self.credentials.sign_bytes(to_sign) + assert crypt.verify_signature(to_sign, signature, test_jwt.PUBLIC_CERT_BYTES) + + def test_signer(self): + assert isinstance(self.credentials.signer, crypt.RSASigner) + + def test_signer_email(self): + assert ( + self.credentials.signer_email + == test_jwt.SERVICE_ACCOUNT_INFO["client_email"] + ) + + def _verify_token(self, token): + payload = jwt_async.decode(token, test_jwt.PUBLIC_CERT_BYTES) + assert payload["iss"] == self.SERVICE_ACCOUNT_EMAIL + return payload + + def test_refresh(self): + self.credentials.refresh(None) + assert self.credentials.valid + assert not self.credentials.expired + + def test_expired(self): + assert not self.credentials.expired + + self.credentials.refresh(None) + assert not self.credentials.expired + + with mock.patch("google.auth._helpers.utcnow") as now: + one_day = datetime.timedelta(days=1) + now.return_value = self.credentials.expiry + one_day + assert self.credentials.expired + + @pytest.mark.asyncio + async def test_before_request(self): + headers = {} + + self.credentials.refresh(None) + await self.credentials.before_request( + None, "GET", "http://example.com?a=1#3", headers + ) + + header_value = headers["authorization"] + _, token = header_value.split(" ") + + # Since the audience is set, it should use the existing token. + assert token.encode("utf-8") == self.credentials.token + + payload = self._verify_token(token) + assert payload["aud"] == self.AUDIENCE + + @pytest.mark.asyncio + async def test_before_request_refreshes(self): + assert not self.credentials.valid + await self.credentials.before_request( + None, "GET", "http://example.com?a=1#3", {} + ) + assert self.credentials.valid + + +class TestOnDemandCredentials(object): + SERVICE_ACCOUNT_EMAIL = "service-account@example.com" + SUBJECT = "subject" + ADDITIONAL_CLAIMS = {"meta": "data"} + credentials = None + + @pytest.fixture(autouse=True) + def credentials_fixture(self, signer): + self.credentials = jwt_async.OnDemandCredentials( + signer, + self.SERVICE_ACCOUNT_EMAIL, + self.SERVICE_ACCOUNT_EMAIL, + max_cache_size=2, + ) + + def test_from_service_account_info(self): + with open(test_jwt.SERVICE_ACCOUNT_JSON_FILE, "r") as fh: + info = json.load(fh) + + credentials = jwt_async.OnDemandCredentials.from_service_account_info(info) + + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._issuer == info["client_email"] + assert credentials._subject == info["client_email"] + + def test_from_service_account_info_args(self): + info = test_jwt.SERVICE_ACCOUNT_INFO.copy() + + credentials = jwt_async.OnDemandCredentials.from_service_account_info( + info, subject=self.SUBJECT, additional_claims=self.ADDITIONAL_CLAIMS + ) + + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._issuer == info["client_email"] + assert credentials._subject == self.SUBJECT + assert credentials._additional_claims == self.ADDITIONAL_CLAIMS + + def test_from_service_account_file(self): + info = test_jwt.SERVICE_ACCOUNT_INFO.copy() + + credentials = jwt_async.OnDemandCredentials.from_service_account_file( + test_jwt.SERVICE_ACCOUNT_JSON_FILE + ) + + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._issuer == info["client_email"] + assert credentials._subject == info["client_email"] + + def test_from_service_account_file_args(self): + info = test_jwt.SERVICE_ACCOUNT_INFO.copy() + + credentials = jwt_async.OnDemandCredentials.from_service_account_file( + test_jwt.SERVICE_ACCOUNT_JSON_FILE, + subject=self.SUBJECT, + additional_claims=self.ADDITIONAL_CLAIMS, + ) + + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._issuer == info["client_email"] + assert credentials._subject == self.SUBJECT + assert credentials._additional_claims == self.ADDITIONAL_CLAIMS + + def test_from_signing_credentials(self): + jwt_from_signing = self.credentials.from_signing_credentials(self.credentials) + jwt_from_info = jwt_async.OnDemandCredentials.from_service_account_info( + test_jwt.SERVICE_ACCOUNT_INFO + ) + + assert isinstance(jwt_from_signing, jwt_async.OnDemandCredentials) + assert jwt_from_signing._signer.key_id == jwt_from_info._signer.key_id + assert jwt_from_signing._issuer == jwt_from_info._issuer + assert jwt_from_signing._subject == jwt_from_info._subject + + def test_default_state(self): + # Credentials are *always* valid. + assert self.credentials.valid + # Credentials *never* expire. + assert not self.credentials.expired + + def test_with_claims(self): + new_claims = {"meep": "moop"} + new_credentials = self.credentials.with_claims(additional_claims=new_claims) + + assert new_credentials._signer == self.credentials._signer + assert new_credentials._issuer == self.credentials._issuer + assert new_credentials._subject == self.credentials._subject + assert new_credentials._additional_claims == new_claims + + def test_with_quota_project(self): + quota_project_id = "project-foo" + new_credentials = self.credentials.with_quota_project(quota_project_id) + + assert new_credentials._signer == self.credentials._signer + assert new_credentials._issuer == self.credentials._issuer + assert new_credentials._subject == self.credentials._subject + assert new_credentials._additional_claims == self.credentials._additional_claims + assert new_credentials._quota_project_id == quota_project_id + + def test_sign_bytes(self): + to_sign = b"123" + signature = self.credentials.sign_bytes(to_sign) + assert crypt.verify_signature(to_sign, signature, test_jwt.PUBLIC_CERT_BYTES) + + def test_signer(self): + assert isinstance(self.credentials.signer, crypt.RSASigner) + + def test_signer_email(self): + assert ( + self.credentials.signer_email + == test_jwt.SERVICE_ACCOUNT_INFO["client_email"] + ) + + def _verify_token(self, token): + payload = jwt_async.decode(token, test_jwt.PUBLIC_CERT_BYTES) + assert payload["iss"] == self.SERVICE_ACCOUNT_EMAIL + return payload + + def test_refresh(self): + with pytest.raises(exceptions.RefreshError): + self.credentials.refresh(None) + + def test_before_request(self): + headers = {} + + self.credentials.before_request( + None, "GET", "http://example.com?a=1#3", headers + ) + + _, token = headers["authorization"].split(" ") + payload = self._verify_token(token) + + assert payload["aud"] == "http://example.com" + + # Making another request should re-use the same token. + self.credentials.before_request(None, "GET", "http://example.com?b=2", headers) + + _, new_token = headers["authorization"].split(" ") + + assert new_token == token + + def test_expired_token(self): + self.credentials._cache["audience"] = ( + mock.sentinel.token, + datetime.datetime.min, + ) + + token = self.credentials._get_jwt_for_audience("audience") + + assert token != mock.sentinel.token diff --git a/packages/google-auth/tests_async/transport/__init__.py b/packages/google-auth/tests_async/transport/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/google-auth/tests_async/transport/async_compliance.py b/packages/google-auth/tests_async/transport/async_compliance.py new file mode 100644 index 000000000000..f3a36079cfe2 --- /dev/null +++ b/packages/google-auth/tests_async/transport/async_compliance.py @@ -0,0 +1,133 @@ +# 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 http.client as http_client +import time + +import flask # type: ignore +import pytest # type: ignore +from pytest_localserver.http import WSGIServer # type: ignore + +from google.auth import exceptions +from tests.transport import compliance + + +class RequestResponseTests(object): + @pytest.fixture(scope="module") + def server(self): + """Provides a test HTTP server. + + The test server is automatically created before + a test and destroyed at the end. The server is serving a test + application that can be used to verify requests. + """ + app = flask.Flask(__name__) + app.debug = True + + # pylint: disable=unused-variable + # (pylint thinks the flask routes are unusued.) + @app.route("/basic") + def index(): + header_value = flask.request.headers.get("x-test-header", "value") + headers = {"X-Test-Header": header_value} + return "Basic Content", http_client.OK, headers + + @app.route("/server_error") + def server_error(): + return "Error", http_client.INTERNAL_SERVER_ERROR + + @app.route("/wait") + def wait(): + time.sleep(3) + return "Waited" + + # pylint: enable=unused-variable + + server = WSGIServer(application=app.wsgi_app) + server.start() + yield server + server.stop() + + @pytest.mark.asyncio + async def test_request_basic(self, server): + request = self.make_request() + response = await request(url=server.url + "/basic", method="GET") + assert response.status == http_client.OK + assert response.headers["x-test-header"] == "value" + + # Use 13 as this is the length of the data written into the stream. + + data = await response.data.read(13) + assert data == b"Basic Content" + + @pytest.mark.asyncio + async def test_request_basic_with_http(self, server): + request = self.make_with_parameter_request() + response = await request(url=server.url + "/basic", method="GET") + assert response.status == http_client.OK + assert response.headers["x-test-header"] == "value" + + # Use 13 as this is the length of the data written into the stream. + + data = await response.data.read(13) + assert data == b"Basic Content" + + @pytest.mark.asyncio + async def test_request_with_timeout_success(self, server): + request = self.make_request() + response = await request(url=server.url + "/basic", method="GET", timeout=2) + + assert response.status == http_client.OK + assert response.headers["x-test-header"] == "value" + + data = await response.data.read(13) + assert data == b"Basic Content" + + @pytest.mark.asyncio + async def test_request_with_timeout_failure(self, server): + request = self.make_request() + + with pytest.raises(exceptions.TransportError): + await request(url=server.url + "/wait", method="GET", timeout=1) + + @pytest.mark.asyncio + async def test_request_headers(self, server): + request = self.make_request() + response = await request( + url=server.url + "/basic", + method="GET", + headers={"x-test-header": "hello world"}, + ) + + assert response.status == http_client.OK + assert response.headers["x-test-header"] == "hello world" + + data = await response.data.read(13) + assert data == b"Basic Content" + + @pytest.mark.asyncio + async def test_request_error(self, server): + request = self.make_request() + + response = await request(url=server.url + "/server_error", method="GET") + assert response.status == http_client.INTERNAL_SERVER_ERROR + data = await response.data.read(5) + assert data == b"Error" + + @pytest.mark.asyncio + async def test_connection_error(self): + request = self.make_request() + + with pytest.raises(exceptions.TransportError): + await request(url="http://{}".format(compliance.NXDOMAIN), method="GET") diff --git a/packages/google-auth/tests_async/transport/test_aiohttp_requests.py b/packages/google-auth/tests_async/transport/test_aiohttp_requests.py new file mode 100644 index 000000000000..7bb709183ad2 --- /dev/null +++ b/packages/google-auth/tests_async/transport/test_aiohttp_requests.py @@ -0,0 +1,253 @@ +# 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 aiohttp # type: ignore +from aioresponses import aioresponses, core # type: ignore +import mock +import pytest # type: ignore +from tests_async.transport import async_compliance + +import google.auth._credentials_async +from google.auth.transport import _aiohttp_requests as aiohttp_requests +import google.auth.transport._mtls_helper + + +class TestCombinedResponse: + @pytest.mark.asyncio + async def test__is_compressed(self): + response = core.CallbackResult(headers={"Content-Encoding": "gzip"}) + combined_response = aiohttp_requests._CombinedResponse(response) + compressed = combined_response._is_compressed() + assert compressed + + def test__is_compressed_not(self): + response = core.CallbackResult(headers={"Content-Encoding": "not"}) + combined_response = aiohttp_requests._CombinedResponse(response) + compressed = combined_response._is_compressed() + assert not compressed + + @pytest.mark.asyncio + async def test_raw_content(self): + mock_response = mock.AsyncMock() + mock_response.content.read.return_value = mock.sentinel.read + combined_response = aiohttp_requests._CombinedResponse(response=mock_response) + raw_content = await combined_response.raw_content() + assert raw_content == mock.sentinel.read + + # Second call to validate the preconfigured path. + combined_response._raw_content = mock.sentinel.stored_raw + raw_content = await combined_response.raw_content() + assert raw_content == mock.sentinel.stored_raw + + @pytest.mark.asyncio + async def test_content(self): + mock_response = mock.AsyncMock() + mock_response.content.read.return_value = mock.sentinel.read + combined_response = aiohttp_requests._CombinedResponse(response=mock_response) + content = await combined_response.content() + assert content == mock.sentinel.read + + @mock.patch( + "google.auth.transport._aiohttp_requests.urllib3.response.MultiDecoder.decompress", + return_value="decompressed", + autospec=True, + ) + @pytest.mark.asyncio + async def test_content_compressed(self, urllib3_mock): + rm = core.RequestMatch( + "url", headers={"Content-Encoding": "gzip"}, payload="compressed" + ) + response = await rm.build_response(core.URL("url")) + + combined_response = aiohttp_requests._CombinedResponse(response=response) + content = await combined_response.content() + + urllib3_mock.assert_called_once() + assert content == "decompressed" + + +class TestResponse: + def test_ctor(self): + response = aiohttp_requests._Response(mock.sentinel.response) + assert response._response == mock.sentinel.response + + @pytest.mark.asyncio + async def test_headers_prop(self): + rm = core.RequestMatch("url", headers={"Content-Encoding": "header prop"}) + mock_response = await rm.build_response(core.URL("url")) + + response = aiohttp_requests._Response(mock_response) + assert response.headers["Content-Encoding"] == "header prop" + + @pytest.mark.asyncio + async def test_status_prop(self): + rm = core.RequestMatch("url", status=123) + mock_response = await rm.build_response(core.URL("url")) + response = aiohttp_requests._Response(mock_response) + assert response.status == 123 + + @pytest.mark.asyncio + async def test_data_prop(self): + mock_response = mock.AsyncMock() + mock_response.content.read.return_value = mock.sentinel.read + response = aiohttp_requests._Response(mock_response) + data = await response.data.read() + assert data == mock.sentinel.read + + +class TestRequestResponse(async_compliance.RequestResponseTests): + def make_request(self): + return aiohttp_requests.Request() + + def make_with_parameter_request(self): + http = aiohttp.ClientSession(auto_decompress=False) + return aiohttp_requests.Request(http) + + def test_unsupported_session(self): + http = aiohttp.ClientSession(auto_decompress=True) + with pytest.raises(ValueError): + aiohttp_requests.Request(http) + + def test_timeout(self): + http = mock.create_autospec( + aiohttp.ClientSession, instance=True, _auto_decompress=False + ) + request = aiohttp_requests.Request(http) + request(url="http://example.com", method="GET", timeout=5) + + +class CredentialsStub(google.auth._credentials_async.Credentials): + def __init__(self, token="token"): + super(CredentialsStub, self).__init__() + self.token = token + + def apply(self, headers, token=None): + headers["authorization"] = self.token + + def refresh(self, request): + self.token += "1" + + +class TestAuthorizedSession(object): + TEST_URL = "http://example.com/" + method = "GET" + + def test_constructor(self): + authed_session = aiohttp_requests.AuthorizedSession(mock.sentinel.credentials) + assert authed_session.credentials == mock.sentinel.credentials + + def test_constructor_with_auth_request(self): + http = mock.create_autospec( + aiohttp.ClientSession, instance=True, _auto_decompress=False + ) + auth_request = aiohttp_requests.Request(http) + + authed_session = aiohttp_requests.AuthorizedSession( + mock.sentinel.credentials, auth_request=auth_request + ) + + assert authed_session._auth_request == auth_request + + @pytest.mark.asyncio + async def test_request(self): + with aioresponses() as mocked: + credentials = mock.Mock(wraps=CredentialsStub()) + + mocked.get(self.TEST_URL, status=200, body="test") + session = aiohttp_requests.AuthorizedSession(credentials) + resp = await session.request( + "GET", + "http://example.com/", + headers={"Keep-Alive": "timeout=5, max=1000", "fake": b"bytes"}, + ) + + assert resp.status == 200 + assert "test" == await resp.text() + + await session.close() + + @pytest.mark.asyncio + async def test_ctx(self): + with aioresponses() as mocked: + credentials = mock.Mock(wraps=CredentialsStub()) + mocked.get("http://test.example.com", payload=dict(foo="bar")) + session = aiohttp_requests.AuthorizedSession(credentials) + resp = await session.request("GET", "http://test.example.com") + data = await resp.json() + + assert dict(foo="bar") == data + + await session.close() + + @pytest.mark.asyncio + async def test_http_headers(self): + with aioresponses() as mocked: + credentials = mock.Mock(wraps=CredentialsStub()) + mocked.post( + "http://example.com", + payload=dict(), + headers=dict(connection="keep-alive"), + ) + + session = aiohttp_requests.AuthorizedSession(credentials) + resp = await session.request("POST", "http://example.com") + + assert resp.headers["Connection"] == "keep-alive" + + await session.close() + + @pytest.mark.asyncio + async def test_regexp_example(self): + with aioresponses() as mocked: + credentials = mock.Mock(wraps=CredentialsStub()) + mocked.get("http://example.com", status=500) + mocked.get("http://example.com", status=200) + + session1 = aiohttp_requests.AuthorizedSession(credentials) + + resp1 = await session1.request("GET", "http://example.com") + session2 = aiohttp_requests.AuthorizedSession(credentials) + resp2 = await session2.request("GET", "http://example.com") + + assert resp1.status == 500 + assert resp2.status == 200 + + await session1.close() + await session2.close() + + @pytest.mark.asyncio + async def test_request_no_refresh(self): + credentials = mock.Mock(wraps=CredentialsStub()) + with aioresponses() as mocked: + mocked.get("http://example.com", status=200) + authed_session = aiohttp_requests.AuthorizedSession(credentials) + response = await authed_session.request("GET", "http://example.com") + assert response.status == 200 + assert credentials.before_request.called + assert not credentials.refresh.called + + await authed_session.close() + + @pytest.mark.asyncio + async def test_request_refresh(self): + credentials = mock.Mock(wraps=CredentialsStub()) + with aioresponses() as mocked: + mocked.get("http://example.com", status=401) + mocked.get("http://example.com", status=200) + authed_session = aiohttp_requests.AuthorizedSession(credentials) + response = await authed_session.request("GET", "http://example.com") + assert credentials.refresh.called + assert response.status == 200 + + await authed_session.close() diff --git a/scripts/split_repo_migration/single-library.git-migrate-history.sh b/scripts/split_repo_migration/single-library.git-migrate-history.sh index 0181b6368162..9f926e1d030e 100755 --- a/scripts/split_repo_migration/single-library.git-migrate-history.sh +++ b/scripts/split_repo_migration/single-library.git-migrate-history.sh @@ -70,7 +70,7 @@ echo "Created working directory: ${WORKDIR}" pushd "${WORKDIR}" # cd into workdir echo "Cloning source repository: ${SOURCE_REPO}" -git clone "git@github.com:${SOURCE_REPO}.git" source-repo +git clone --recurse-submodules "git@github.com:${SOURCE_REPO}.git" source-repo pushd source-repo @@ -124,7 +124,7 @@ git filter-branch \ --force \ --prune-empty \ --tree-filter \ - "shopt -s dotglob; mkdir -p ${WORKDIR}/migrated-source; mv * ${WORKDIR}/migrated-source; mkdir -p ${TARGET_PATH}; { mv ${WORKDIR}/migrated-source/* ${TARGET_PATH} || echo 'No files to move' ; }" + "git submodule update --init --recursive; find . -mindepth 2 -name .git -exec rm -rf {} +; shopt -s dotglob; mkdir -p ${WORKDIR}/migrated-source; mv * ${WORKDIR}/migrated-source; mkdir -p ${TARGET_PATH}; { mv ${WORKDIR}/migrated-source/* ${TARGET_PATH} || echo 'No files to move' ; }" # back to workdir popd @@ -142,8 +142,8 @@ echo "Success" popd # back to workdir # Do a diff between source code split repo and migrated code. -git clone "git@github.com:${SOURCE_REPO}.git" source-repo-validation # Not ideal to clone again. -rm -rf source-repo-validation/.git # That folder is not needed for validation. +git clone --recurse-submodules "git@github.com:${SOURCE_REPO}.git" source-repo-validation # Not ideal to clone again. +find source-repo-validation -name .git -exec rm -rf {} + # That folder is not needed for validation. DIFF_FILE="${WORKDIR}/diff.txt" if diff -r "${TARGET_REPO}/${TARGET_PATH}" source-repo-validation > "${DIFF_FILE}" ; then