diff --git a/.github/run-package-tests.sh b/.github/run-package-tests.sh index 89a88115fdd1..489ce26317cd 100644 --- a/.github/run-package-tests.sh +++ b/.github/run-package-tests.sh @@ -70,6 +70,7 @@ run_package_test() { # Update composer to use local packages local PACKAGE_DEPENDENCIES=( + "Auth,auth" "Gax,gax" "CommonProtos,common-protos,4.100" "BigQuery,cloud-bigquery" @@ -78,7 +79,7 @@ run_package_test() { "PubSub,cloud-pubsub" "Storage,cloud-storage,2.100" "ShoppingCommonProtos,shopping-common-protos" - "GeoCommonProtos,geo-common-protos,0.1", + "GeoCommonProtos,geo-common-protos,0.1" "Monitoring,cloud-monitoring" ) for i in "${PACKAGE_DEPENDENCIES[@]}"; do diff --git a/.github/workflows/release-checks.yaml b/.github/workflows/release-checks.yaml index b1fbb6fceaa9..65118767b9ad 100644 --- a/.github/workflows/release-checks.yaml +++ b/.github/workflows/release-checks.yaml @@ -145,4 +145,5 @@ jobs: --format=ci \ -t $GH_TOKEN \ -p $PG_TOKEN \ - --skip Gax:repo # Skip repo check for Gax because issues are enabled + --skip Gax:repo \ + --skip Auth:repo # Skip repo check for Gax and Auth because issues are enabled diff --git a/.github/workflows/unit-tests.yaml b/.github/workflows/unit-tests.yaml index e00e2041eeca..ac1d5c2339db 100644 --- a/.github/workflows/unit-tests.yaml +++ b/.github/workflows/unit-tests.yaml @@ -41,7 +41,7 @@ jobs: uses: shivammathur/cache-extensions@de3c642a5fce0ef91581a1c9831e229f525196d6 # v1 with: php-version: ${{ matrix.php }} - extensions: sodium, sysvshm, ${{ matrix.extensions }} + extensions: sodium, sysvshm, gmp, ${{ matrix.extensions }} key: cache-key-1 # increment to bust the cache - name: Cache extensions @@ -55,7 +55,7 @@ jobs: uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 with: php-version: ${{ matrix.php }} - extensions: sodium, sysvshm, ${{ matrix.extensions }} + extensions: sodium, sysvshm, gmp, ${{ matrix.extensions }} - name: Install Dependencies uses: nick-invision/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4 @@ -83,7 +83,7 @@ jobs: uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 with: php-version: '8.1' - extensions: grpc + extensions: grpc, gmp - name: Run Package Test Suites run: bash .github/run-package-tests.sh @@ -98,7 +98,7 @@ jobs: uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 with: php-version: '8.1' - extensions: grpc + extensions: grpc, gmp - name: Run Package Test Suites env: PREFER_LOWEST: ${{ github.event.pull_request.user.login == 'release-please[bot]' && '--prefer-lowest-strict' || '--prefer-lowest' }} @@ -121,4 +121,3 @@ jobs: run: dev/vendor/bin/phpunit -c dev/phpunit.xml.dist - name: Run Dev Snippet Test Suite run: dev/vendor/bin/phpunit -c dev/phpunit-snippets.xml.dist - diff --git a/.kokoro/docs/publish.sh b/.kokoro/docs/publish.sh index b34c002f0fe1..fdb99e9f3066 100755 --- a/.kokoro/docs/publish.sh +++ b/.kokoro/docs/publish.sh @@ -91,15 +91,6 @@ if [ ${#DIR_ARRAY[@]} -gt 0 ]; then printf "%s\n" "${DIR_ARRAY[@]}" | xargs -P "${MAX_JOBS}" -I {} bash -c 'run_docfx_parallel "$@"' _ {} fi -# Add Auth repo -AUTH_DIR=$PROJECT_DIR/dev/vendor/google/auth -$PROJECT_DIR/dev/google-cloud docfx \ - --path $AUTH_DIR \ - --out auth-out \ - --metadata-version $(cat $AUTH_DIR/VERSION) \ - $STAGING_FLAG \ - $VERBOSITY_FLAG - # Add protobuf PROTOBUF_DIR=$PROJECT_DIR/dev/vendor/google/protobuf PROTOBUF_VERSION=$(composer info google/protobuf -f json -d $PROJECT_DIR/dev | jq -r .versions[0]) diff --git a/.repo-metadata-full.json b/.repo-metadata-full.json index fd0693c4c6ee..f7677195df52 100644 --- a/.repo-metadata-full.json +++ b/.repo-metadata-full.json @@ -209,6 +209,14 @@ "library_type": "GAPIC_AUTO", "api_shortname": "auditmanager" }, + "Auth": { + "language": "php", + "distribution_name": "google/auth", + "release_level": "stable", + "client_documentation": "https://cloud.google.com/php/docs/reference/auth/latest", + "library_type": "CORE", + "api_shortname": "" + }, "AutoMl": { "language": "php", "distribution_name": "google/cloud-automl", diff --git a/Auth/.editorconfig b/Auth/.editorconfig new file mode 100644 index 000000000000..6bc23e62f322 --- /dev/null +++ b/Auth/.editorconfig @@ -0,0 +1,18 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true +charset = utf-8 + +# Get rid of whitespace to avoid diffs with a bunch of EOL changes +trim_trailing_whitespace = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true + +# PHP-Files +[*.php] +indent_style = space +indent_size = 4 diff --git a/Auth/.gitattributes b/Auth/.gitattributes new file mode 100644 index 000000000000..76fd34749d6b --- /dev/null +++ b/Auth/.gitattributes @@ -0,0 +1,12 @@ +.editorconfig export-ignore +.gcp export-ignore +.gitattributes export-ignore +.github export-ignore +.gitignore export-ignore +phpstan.neon.dist export-ignore +phpunit.xml.dist export-ignore +.php-cs-fixer.dist.php export-ignore +CHANGELOG.md export-ignore +CODE_OF_CONDUCT.md export-ignore +renovate.json export-ignore +tests export-ignore diff --git a/Auth/.github/pull_request_template.md b/Auth/.github/pull_request_template.md new file mode 100644 index 000000000000..9405fd182443 --- /dev/null +++ b/Auth/.github/pull_request_template.md @@ -0,0 +1,24 @@ +**PLEASE READ THIS ENTIRE MESSAGE** + +Hello, and thank you for your contribution! Please note that this repository is +a read-only split of `googleapis/google-cloud-php`. As such, we are +unable to accept pull requests to this repository. + +We welcome your pull request and would be happy to consider it for inclusion in +our library if you follow these steps: + +* Clone the parent client library repository: + +```sh +$ git clone git@github.com:googleapis/google-cloud-php.git +``` + +* Move your changes into the correct location in that library. Library code +belongs in `Auth/src`, and tests in `Auth/tests`. + +* Push the changes in a new branch to a fork, and open a new pull request +[here](https://github.com/googleapis/google-cloud-php). + +Thanks again, and we look forward to seeing your proposed change! + +The Google Cloud PHP team diff --git a/Auth/.gitignore b/Auth/.gitignore new file mode 100644 index 000000000000..b958dd074f61 --- /dev/null +++ b/Auth/.gitignore @@ -0,0 +1,12 @@ +*~ +vendor +composer.lock +.cache +.docs +.gitmodules +.phpunit.result.cache + +# IntelliJ +.idea +*.iml +.php-cs-fixer.cache diff --git a/Auth/CHANGELOG.md b/Auth/CHANGELOG.md new file mode 100644 index 000000000000..49520756777e --- /dev/null +++ b/Auth/CHANGELOG.md @@ -0,0 +1,558 @@ +## 1.21.0 (04/13/2022) + + * [feat]: add support for Firebase v6.0 (#391) + +## [1.53.0](https://github.com/googleapis/google-auth-library-php/compare/v1.52.0...v1.53.0) (2026-07-22) + + +### Features + +* Add support for Guzzle 8 ([#677](https://github.com/googleapis/google-auth-library-php/issues/677)) ([6f4c5f6](https://github.com/googleapis/google-auth-library-php/commit/6f4c5f655607edfc332e6e9b63742cf13bbcfdd3)) +* Add workload identity federation support for AWS ECS tasks ([#496](https://github.com/googleapis/google-auth-library-php/issues/496)) ([#674](https://github.com/googleapis/google-auth-library-php/issues/674)) ([ed26eec](https://github.com/googleapis/google-auth-library-php/commit/ed26eec39b983ec00b26f0ff42a82d1786ff7f73)) + +## [1.52.0](https://github.com/googleapis/google-auth-library-php/compare/v1.51.0...v1.52.0) (2026-06-23) + + +### Features + +* Regional Access Boundaries ([#649](https://github.com/googleapis/google-auth-library-php/issues/649)) ([fe228ee](https://github.com/googleapis/google-auth-library-php/commit/fe228ee192c49e4e84065b6fbd619fe787319f63)) + +## [1.51.0](https://github.com/googleapis/google-auth-library-php/compare/v1.50.2...v1.51.0) (2026-06-09) + + +### Features + +* Add ExternalAccountAuthorizedUser credentials ([#662](https://github.com/googleapis/google-auth-library-php/issues/662)) ([47b05b0](https://github.com/googleapis/google-auth-library-php/commit/47b05b060befcd7c76387c7763055705f69d4db0)) + +## [1.50.1](https://github.com/googleapis/google-auth-library-php/compare/v1.50.0...v1.50.1) (2026-03-18) + + +### Bug Fixes + +* ImpersonatedCredentials getLastReceivedToken returns correct token ([#655](https://github.com/googleapis/google-auth-library-php/issues/655)) ([a4fe69c](https://github.com/googleapis/google-auth-library-php/commit/a4fe69c11c0c8bbe78e33eb1433c6dbcec72af2a)) +* Support psr/log 2 ([#654](https://github.com/googleapis/google-auth-library-php/issues/654)) ([4b746b8](https://github.com/googleapis/google-auth-library-php/commit/4b746b844ff60d86c6dcc940eb250a0753919181)) + +## [1.50.0](https://github.com/googleapis/google-auth-library-php/compare/v1.49.0...v1.50.0) (2026-01-08) + + +### Features + +* Support firebase/php-jwt 7 ([#645](https://github.com/googleapis/google-auth-library-php/issues/645)) ([ae52b0a](https://github.com/googleapis/google-auth-library-php/commit/ae52b0aaa0d67c7e36569ab0f3ea3b3cf45c9e6a)) + +## [1.49.0](https://github.com/googleapis/google-auth-library-php/compare/v1.48.1...v1.49.0) (2025-11-06) + + +### Features + +* Add semaphore locking to Sysv cache ([#640](https://github.com/googleapis/google-auth-library-php/issues/640)) ([38ea069](https://github.com/googleapis/google-auth-library-php/commit/38ea069652278928f55335fc6c4ed92be866cf0f)) +* Json key scopes in ImpersonatedServiceAccountCredentials ([#638](https://github.com/googleapis/google-auth-library-php/issues/638)) ([b6b6966](https://github.com/googleapis/google-auth-library-php/commit/b6b696696245519bbf50222514189dc7a1010bf7)) + + +### Bug Fixes + +* Filecache race condition ([#637](https://github.com/googleapis/google-auth-library-php/issues/637)) ([09042be](https://github.com/googleapis/google-auth-library-php/commit/09042be363a275b5055dce28e8c7ce10455376d9)) + +## [1.48.1](https://github.com/googleapis/google-auth-library-php/compare/v1.48.0...v1.48.1) (2025-09-29) + + +### Bug Fixes + +* Remove deprecated Item class for the CacheItemPool ([#631](https://github.com/googleapis/google-auth-library-php/issues/631)) ([7ec42c6](https://github.com/googleapis/google-auth-library-php/commit/7ec42c6ccc678865958766a32e888ae986a13608)) + +## [1.48.0](https://github.com/googleapis/google-auth-library-php/compare/v1.47.1...v1.48.0) (2025-09-16) + + +### Features + +* Add the rpcName to the logged event ([#630](https://github.com/googleapis/google-auth-library-php/issues/630)) ([d1d9e21](https://github.com/googleapis/google-auth-library-php/commit/d1d9e214af6a67bba4f06a2906be1be7da469419)) + + +### Bug Fixes + +* Deprecate Credentials::makeCredentials ([#624](https://github.com/googleapis/google-auth-library-php/issues/624)) ([12bb6e8](https://github.com/googleapis/google-auth-library-php/commit/12bb6e8a137f0dce5e2f1c193d59df8596fde3e4)) + +## [1.47.1](https://github.com/googleapis/google-auth-library-php/compare/v1.47.0...v1.47.1) (2025-07-08) + + +### Bug Fixes + +* Undefined index error in cache trait ([#617](https://github.com/googleapis/google-auth-library-php/issues/617)) ([ff7ece6](https://github.com/googleapis/google-auth-library-php/commit/ff7ece65bab1e5131ef57181678cc83e04c93aef)) + +## [1.47.0](https://github.com/googleapis/google-auth-library-php/compare/v1.46.0...v1.47.0) (2025-04-15) + + +### Features + +* Add support for $_ENV in CredentialsLoader ([#612](https://github.com/googleapis/google-auth-library-php/issues/612)) ([3e63576](https://github.com/googleapis/google-auth-library-php/commit/3e63576bf73ab8c7a0cccecd741601c4d6800e6d)) + +## [1.46.0](https://github.com/googleapis/google-auth-library-php/compare/v1.45.4...v1.46.0) (2025-02-12) + + +### Features + +* Add support for Impersonating ID Tokens ([#580](https://github.com/googleapis/google-auth-library-php/issues/580)) ([66db27c](https://github.com/googleapis/google-auth-library-php/commit/66db27c671c229ff561ecab51e0b6379c6109b93)) + +## [1.45.4](https://github.com/googleapis/google-auth-library-php/compare/v1.45.3...v1.45.4) (2025-02-05) + + +### Bug Fixes + +* Return impersonated token as lastReceivedToken ([#606](https://github.com/googleapis/google-auth-library-php/issues/606)) ([33c3c85](https://github.com/googleapis/google-auth-library-php/commit/33c3c850973487951c6cb943d200702ba6debbb0)) + +## [1.45.3](https://github.com/googleapis/google-auth-library-php/compare/v1.45.2...v1.45.3) (2025-02-05) + + +### Documentation + +* fix broken reference (#602) + +## [1.45.2](https://github.com/googleapis/google-auth-library-php/compare/v1.45.1...v1.45.2) (2025-01-28) + + +### Bug Fixes + +* Minor changes to allow for refdoc regeneration ([#600](https://github.com/googleapis/google-auth-library-php/issues/600)) ([608115c](https://github.com/googleapis/google-auth-library-php/commit/608115cd17fda4498ddb7d22a47ee06316e0d0cf)) + +## [1.45.0](https://github.com/googleapis/google-auth-library-php/compare/v1.44.0...v1.45.0) (2024-12-11) + + +### Features + +* Add StdOutLogger and LoggingTrait ([#578](https://github.com/googleapis/google-auth-library-php/issues/578)) ([4f793fe](https://github.com/googleapis/google-auth-library-php/commit/4f793fe3e31db8f71a3a0f17ae528a4d93d6bd2a)) + +## [1.44.0](https://github.com/googleapis/google-auth-library-php/compare/v1.43.0...v1.44.0) (2024-12-04) + + +### Features + +* Add service account impersonation for access tokens ([#586](https://github.com/googleapis/google-auth-library-php/issues/586)) ([ba137b2](https://github.com/googleapis/google-auth-library-php/commit/ba137b2db9ed7ce002cfb4034a1e8d354a85e2fc)) + + +### Bug Fixes + +* Add support for php 8.4, remove implicit nullable ([#591](https://github.com/googleapis/google-auth-library-php/issues/591)) ([3e1061b](https://github.com/googleapis/google-auth-library-php/commit/3e1061bba19d9340407a9ff70b7b7294c344d17c)) + +## [1.43.0](https://github.com/googleapis/google-auth-library-php/compare/v1.42.0...v1.43.0) (2024-11-05) + + +### Features + +* Add ID tokens for user refresh credentials ([#468](https://github.com/googleapis/google-auth-library-php/issues/468)) ([1601efc](https://github.com/googleapis/google-auth-library-php/commit/1601efc2f1f362437beda2c4212f1f471568dee6)) +* Call IamCredentials endpoint for generating ID tokens outside GDU ([#581](https://github.com/googleapis/google-auth-library-php/issues/581)) ([2d7d03d](https://github.com/googleapis/google-auth-library-php/commit/2d7d03d0cac08c8d6e03276f14ef260ccb980b7c)) + + +### Bug Fixes + +* Compatability with php 8.4 ([#584](https://github.com/googleapis/google-auth-library-php/issues/584)) ([da1f02a](https://github.com/googleapis/google-auth-library-php/commit/da1f02a8dcbbbafd325cf0c9ac3462a72eb387fb)) +* Update universe domain URI ([#572](https://github.com/googleapis/google-auth-library-php/issues/572)) ([6b00b66](https://github.com/googleapis/google-auth-library-php/commit/6b00b66f9a879b545ffb6f2416cc2add88be3be1)) + +## [1.42.0](https://github.com/googleapis/google-auth-library-php/compare/v1.41.0...v1.42.0) (2024-08-26) + + +### Features + +* Add a file system cache class ([#571](https://github.com/googleapis/google-auth-library-php/issues/571)) ([8555cb0](https://github.com/googleapis/google-auth-library-php/commit/8555cb063caa5571f80d9605969411b894ee6eb0)) +* Private key getters on service account credentials (https://github.com/googleapis/google-auth-library-php/pull/557) ([d2fa07b](https://github.com/googleapis/google-auth-library-php/commit/d2fa07b8a8edfa65c1bd732dac794c070e3451bc)) + +## [1.41.0](https://github.com/googleapis/google-auth-library-php/compare/v1.40.0...v1.41.0) (2024-07-10) + + +### Features + +* Change getCacheKey implementation for more unique keys ([#560](https://github.com/googleapis/google-auth-library-php/issues/560)) ([a35c4db](https://github.com/googleapis/google-auth-library-php/commit/a35c4dbb52e01faedacd09d23634939ced4a8a63)) + +## [1.40.0](https://github.com/googleapis/google-auth-library-php/compare/v1.39.0...v1.40.0) (2024-05-31) + + +### Features + +* Add windows residency check ([#553](https://github.com/googleapis/google-auth-library-php/issues/553)) ([ec13a53](https://github.com/googleapis/google-auth-library-php/commit/ec13a53ddd625265b7a596817eb052c693ab89e2)) + +## [1.39.0](https://github.com/googleapis/google-auth-library-php/compare/v1.38.0...v1.39.0) (2024-05-02) + + +### Features + +* Enable auth observability metrics ([#509](https://github.com/googleapis/google-auth-library-php/issues/509)) ([6495f31](https://github.com/googleapis/google-auth-library-php/commit/6495f31061d2d51a173a968dbe65db8dfc6ac3cc)) + +## [1.38.0](https://github.com/googleapis/google-auth-library-php/compare/v1.37.1...v1.38.0) (2024-04-24) + + +### Features + +* Add ExecutableSource credentials ([#525](https://github.com/googleapis/google-auth-library-php/issues/525)) ([d98900d](https://github.com/googleapis/google-auth-library-php/commit/d98900d47bb5d6eeeaf64fc2a6a8dbde5797f338)) + +## [1.37.1](https://github.com/googleapis/google-auth-library-php/compare/v1.37.0...v1.37.1) (2024-03-07) + + +### Bug Fixes + +* Use gmdate to format x-amz-date with UTC irrespective of timezone ([#540](https://github.com/googleapis/google-auth-library-php/issues/540)) ([3031d2c](https://github.com/googleapis/google-auth-library-php/commit/3031d2c616902d514867953ede8688d2914d5b11)) + +## [1.37.0](https://github.com/googleapis/google-auth-library-php/compare/v1.36.0...v1.37.0) (2024-02-21) + + +### Features + +* Add caching for universe domain ([#533](https://github.com/googleapis/google-auth-library-php/issues/533)) ([69249ab](https://github.com/googleapis/google-auth-library-php/commit/69249ab03d4852e55377962752bdda5253f3d574)) + +## [1.36.0](https://github.com/googleapis/google-auth-library-php/compare/v1.35.0...v1.36.0) (2024-02-20) + + +### Features + +* Universe domain for Iam ([#531](https://github.com/googleapis/google-auth-library-php/issues/531)) ([b905a56](https://github.com/googleapis/google-auth-library-php/commit/b905a561ac8913420d4b3c0a24734ded48687028)) + +## [1.35.0](https://github.com/googleapis/google-auth-library-php/compare/v1.34.0...v1.35.0) (2024-02-01) + + +### Features + +* Add `expires_in` and `token_type` to tokens from `ServiceAccountJwtAccessCredentials` ([#513](https://github.com/googleapis/google-auth-library-php/issues/513)) ([ee2436d](https://github.com/googleapis/google-auth-library-php/commit/ee2436da42bcf3b2ee09ec8d9eda086293c3a3d9)) +* Workforce credentials ([#485](https://github.com/googleapis/google-auth-library-php/issues/485)) ([c1b240f](https://github.com/googleapis/google-auth-library-php/commit/c1b240f77e5d2b97c481c9d1f23bd57524a22553)) + + +### Bug Fixes + +* Disallow vulnerable guzzle versions ([#520](https://github.com/googleapis/google-auth-library-php/issues/520)) ([cb782dd](https://github.com/googleapis/google-auth-library-php/commit/cb782dd46db94e5ae514c8e66cff6faddfeb4ed8)) + +## [1.34.0](https://github.com/googleapis/google-auth-library-php/compare/v1.33.0...v1.34.0) (2024-01-03) + + +### Features + +* Respect sub for domain-wide delegation in service account creds ([#505](https://github.com/googleapis/google-auth-library-php/issues/505)) ([821d4f3](https://github.com/googleapis/google-auth-library-php/commit/821d4f3e5e496c4dfd5e68e58daaa81484f8af99)) +* Support universe domain in service account and metadata credentials ([#482](https://github.com/googleapis/google-auth-library-php/issues/482)) ([e4aa874](https://github.com/googleapis/google-auth-library-php/commit/e4aa874e2e1dd321f811b09a80f58d42986bf418)) + + +### Bug Fixes + +* ID Token Caching for GCECredentials ([#510](https://github.com/googleapis/google-auth-library-php/issues/510)) ([3222f9e](https://github.com/googleapis/google-auth-library-php/commit/3222f9e5c8d836e21d062ff861b32d3ac867930a)) + +## [1.33.0](https://github.com/googleapis/google-auth-library-php/compare/v1.32.1...v1.33.0) (2023-11-29) + + +### Features + +* Add and implement universe domain interface ([#477](https://github.com/googleapis/google-auth-library-php/issues/477)) ([35781ed](https://github.com/googleapis/google-auth-library-php/commit/35781ed573aa9d831d38452eefbac790559dfb97)) + +### Miscellaneous + +* Refactor `AuthTokenMiddleware` ([#492](https://github.com/googleapis/google-auth-library-php/pull/492)) + +## [1.32.1](https://github.com/googleapis/google-auth-library-php/compare/v1.32.0...v1.32.1) (2023-10-17) + + +### Bug Fixes + +* Allowed_algs not properly set for string value ([#489](https://github.com/googleapis/google-auth-library-php/issues/489)) ([0042b52](https://github.com/googleapis/google-auth-library-php/commit/0042b522ebbcffc6d6623e322d162d963eada3b5)) + +## [1.32.0](https://github.com/googleapis/google-auth-library-php/compare/v1.31.0...v1.32.0) (2023-10-10) + + +### Features + +* Respect cache control for access token certs ([#479](https://github.com/googleapis/google-auth-library-php/issues/479)) ([6d426b5](https://github.com/googleapis/google-auth-library-php/commit/6d426b5cb9462845d2c2d7d506318c9bee613528)) + +## [1.31.0](https://github.com/googleapis/google-auth-library-php/compare/v1.30.0...v1.31.0) (2023-10-05) + + +### Features + +* Add AWS credential source ([#474](https://github.com/googleapis/google-auth-library-php/issues/474)) ([e5bc897](https://github.com/googleapis/google-auth-library-php/commit/e5bc8979bf87159d9acab1ca8cb7cd7af008b2a6)) + +## [1.30.0](https://github.com/googleapis/google-auth-library-php/compare/v1.29.1...v1.30.0) (2023-09-07) + + +### Features + +* Add support for BYOID / STS ([#473](https://github.com/googleapis/google-auth-library-php/issues/473)) ([2938e58](https://github.com/googleapis/google-auth-library-php/commit/2938e58d57ac4ed2c952c930d7ffd6ac69e1abb7)) + +## [1.29.1](https://github.com/googleapis/google-auth-library-php/compare/v1.29.0...v1.29.1) (2023-08-23) + + +### Bug Fixes + +* Use PKCS8 by default for ID token verify ([#466](https://github.com/googleapis/google-auth-library-php/issues/466)) ([0c3a1be](https://github.com/googleapis/google-auth-library-php/commit/0c3a1be78f189e602641b97c487b4092ca17a140)) + +## [1.29.0](https://github.com/googleapis/google-auth-library-php/compare/v1.28.0...v1.29.0) (2023-08-22) + + +### Features + +* Check unix residency for gce when ping fails ([#469](https://github.com/googleapis/google-auth-library-php/issues/469)) ([3c672f9](https://github.com/googleapis/google-auth-library-php/commit/3c672f9aff61529f4af836558caa50fa29fb9447)) + +## [1.28.0](https://github.com/googleapis/google-auth-library-php/compare/v1.27.0...v1.28.0) (2023-05-11) + + +### Features + +* Add pkce support ([#454](https://github.com/googleapis/google-auth-library-php/issues/454)) ([1326c81](https://github.com/googleapis/google-auth-library-php/commit/1326c81c759b8f4694297b3d0686727f56bc9937)) +* Implement quota project from env var in google/auth ([#452](https://github.com/googleapis/google-auth-library-php/issues/452)) ([a9e8ae3](https://github.com/googleapis/google-auth-library-php/commit/a9e8ae3939e2069437ac998201755784b3c54d98)) + +## [1.27.0](https://github.com/googleapis/google-auth-library-php/compare/v1.26.0...v1.27.0) (2023-05-02) + + +### Features + +* **deps:** Add support for psr/http-message 2.0 ([#449](https://github.com/googleapis/google-auth-library-php/issues/449)) ([bc71f90](https://github.com/googleapis/google-auth-library-php/commit/bc71f90ef75681fdcd36cf826c130bfb44435806)) + +## [1.26.0](https://github.com/googleapis/google-auth-library-php/compare/v1.25.0...v1.26.0) (2023-03-30) + + +### Features + +* Access granted scopes ([#441](https://github.com/googleapis/google-auth-library-php/issues/441)) ([3e5c9f1](https://github.com/googleapis/google-auth-library-php/commit/3e5c9f163b6e45c88afc437d41ecb106d8a9951f)) +* Add support for phpseclib3 ([#425](https://github.com/googleapis/google-auth-library-php/issues/425)) ([623acee](https://github.com/googleapis/google-auth-library-php/commit/623acee9b290f14c7402d2b02a2240c6ae37edb2)) + +## [1.25.0](https://github.com/googleapis/google-auth-library-php/compare/v1.24.0...v1.25.0) (2023-01-26) + + +### Features + +* Add getFetcher to FetchAuthTokenCache ([#431](https://github.com/googleapis/google-auth-library-php/issues/431)) ([cf7ac54](https://github.com/googleapis/google-auth-library-php/commit/cf7ac54454bbb8ad6d12c652c05f5d7b5eb2d701)) + +## [1.24.0](https://github.com/googleapis/google-auth-library-php/compare/v1.23.1...v1.24.0) (2022-11-28) + + +### Features + +* Add ImpersonatedServiceAccountCredentials ([#421](https://github.com/googleapis/google-auth-library-php/issues/421)) ([de766e9](https://github.com/googleapis/google-auth-library-php/commit/de766e956645dd114478be918363d06fd928b558)) + +## [1.23.1](https://github.com/googleapis/google-auth-library-php/compare/v1.23.0...v1.23.1) (2022-10-25) + + +### Bug Fixes + +* Do not call GCECredentials::onGCE if ADC has already checked ([#422](https://github.com/googleapis/google-auth-library-php/issues/422)) ([085cc64](https://github.com/googleapis/google-auth-library-php/commit/085cc64c6ae260f917aebf2bc519b4fb6f3400f0)) + +## [1.23.0](https://github.com/googleapis/google-auth-library-php/compare/v1.22.0...v1.23.0) (2022-09-26) + + +### Features + +* Double default truncateAt for guzzle error output ([#415](https://github.com/googleapis/google-auth-library-php/issues/415)) ([e2f6a89](https://github.com/googleapis/google-auth-library-php/commit/e2f6a89ea0edb040db917b47153d2efb04ecd9bb)) + +## 1.20.0 (04/11/2022) + + * [feat]: add support for psr/cache:3 (#364) + * Dropped Support for PHP 5.6 and 7.0 + +## 1.19.0 (03/24/2022) + + * Dropped support for: + * PHP 5.4 and 5.5 + * Guzzle 5 + * Firebase JWT 2.0, 3.0, and 4.0 + +## 1.18.0 (08/24/2021) + + * [feat]: Add support for guzzlehttp/psr7 v2 (#357) + +## 1.17.0 (08/17/2021) + + * [fix]: consistently use useSelfSignedJwt method in ServiceAccountJwtAccessCredentials (#351) + * [feat]: add loading and executing of default client cert source (#353) + * [feat]: add support for proxy-authorization header (#347) + +## 1.16.0 (06/22/2021) + + * [feat]: allow ServiceAccountJwtAccessCredentials to sign scopes (#341) + * [feat]: allow psr/cache:2.0 (#344) + +## 1.15.2 (06/21/2021) + + * [fix]: ensure cached tokens are used for GCECredentials::signBlob (#340) + * [fix]: adds check for getClientName (#336) + +## 1.15.1 (04/21/2021) + + * [fix]: update minimum phpseclib for vulnerability fix (#331) + +## 1.15.0 (02/05/2021) + + * [feat]: support for PHP 8.0: updated dependencies and tests (#318, #319) + +## 1.14.3 (10/16/2020) + + * [fix]: add expires_at to GCECredentials (#314) + +## 1.14.2 (10/14/2020) + +* [fix]: Better FetchAuthTokenCache and getLastReceivedToken (#311) + +## 1.14.1 (10/05/2020) + +* [fix]: variable typo (#310) + +## 1.14.0 (10/02/2020) + +* [feat]: Add support for default scopes (#306) + +## 1.13.0 (9/18/2020) + +* [feat]: Add service account identity support to GCECredentials (#304) + +## 1.12.0 (8/31/2020) + +* [feat]: Add QuotaProject option to getMiddleware (#296) +* [feat]: Add caching for calls to GCECredentials::onGce (#301) +* [feat]: Add updateMetadata function to token cache (#298) +* [fix]: Use quota_project_id instead of quota_project (#299) + +## 1.11.1 (7/27/2020) + +* [fix]: catch ConnectException in GCE check (#294) +* [docs]: Adds [reference docs](https://googleapis.github.io/google-auth-library-php/main) + +## 1.11.0 (7/22/2020) + +* [feat]: Check cache expiration (#291) +* [fix]: OAuth2 cache key when audience is set (#291) + +## 1.10.0 (7/8/2020) + +* [feat]: Add support for Guzzle 7 (#256) +* [fix]: Remove SDK warning (#283) +* [chore]: Switch to github pages deploy action (#284) + +## 1.9.0 (5/14/2020) + +* [feat] Add quotaProject param for extensible client options support (#277) +* [feat] Add signingKeyId param for jwt signing (#270) +* [docs] Misc documentation improvements (#268, #278, #273) +* [chore] Switch from Travis to Github Actions (#273) + +## 1.8.0 (3/26/2020) + +* [feat] Add option to throw exception in AccessToken::verify(). (#265) +* [feat] Add support for x-goog-user-project. (#254) +* [feat] Add option to specify issuer in AccessToken::verify(). (#267) +* [feat] Add getProjectId to credentials types where project IDs can be determined. (#230) + +## 1.7.1 (02/12/2020) + +* [fix] Invalid character in iap cert cache key (#263) +* [fix] Typo in exception for package name (#262) + +## 1.7.0 (02/11/2020) + +* [feat] Add ID token to auth token methods. (#248) +* [feat] Add support for ES256 in `AccessToken::verify`. (#255) +* [fix] Let namespace match the file structure. (#258) +* [fix] Construct RuntimeException. (#257) +* [tests] Update tests for PHP 7.4 compatibility. (#253) +* [chore] Add a couple more things to `.gitattributes`. (#252) + +## 1.6.1 (10/29/2019) + +* [fix] Handle DST correctly for cache item expirations. (#246) + +## 1.6.0 (10/01/2019) + +* [feat] Add utility for verifying and revoking access tokens. (#243) +* [docs] Fix README console terminology. (#242) +* [feat] Support custom scopes with GCECredentials. (#239) +* [fix] Fix phpseclib existence check. (#237) + +## 1.5.2 (07/22/2019) + +* [fix] Move loadItems call out of `SysVCacheItemPool` constructor. (#229) +* [fix] Add `Metadata-Flavor` header to initial GCE metadata call. (#232) + +## 1.5.1 (04/16/2019) + +* [fix] Moved `getClientName()` from `Google\Auth\FetchAuthTokenInterface` + to `Google\Auth\SignBlobInterface`, and removed `getClientName()` from + `InsecureCredentials` and `UserRefreshCredentials`. (#223) + +## 1.5.0 (04/15/2019) + +### Changes + + * Add support for signing strings with a Credentials instance. (#221) + * [Docs] Describe the arrays returned by fetchAuthToken. (#216) + * [Testing] Fix failing tests (#217) + * Update GitHub issue templates (#214, #213) + +## 1.4.0 (09/17/2018) + +### Changes + + * Add support for insecure credentials (#208) + +## 1.3.3 (08/27/2018) + +### Changes + + * Add retry and increase timeout for GCE credentials (#195) + * [Docs] Fix spelling (#204) + * Update token url (#206) + +## 1.3.2 (07/23/2018) + +### Changes + + * Only emits a warning for gcloud credentials (#202) + +## 1.3.1 (07/19/2018) + +### Changes + + * Added a warning for 3 legged OAuth credentials (#199) + * [Code cleanup] Removed useless else after return (#193) + +## 1.3.0 (06/04/2018) + +### Changes + + * Fixes usage of deprecated env var for GAE Flex (#189) + * fix - guzzlehttp/psr7 dependency version definition (#190) + * Added SystemV shared memory based CacheItemPool (#191) + +## 1.2.1 (24/01/2018) + +### Changes + + * Fixes array merging bug in Guzzle5HttpHandler (#186) + * Fixes constructor argument bug in Subscriber & Middleware (#184) + +## 1.2.0 (6/12/2017) + +### Changes + + * Adds async method to HTTP handlers (#176) + * Misc bug fixes and improvements (#177, #175, #178) + +## 1.1.0 (10/10/2017) + +### Changes + + * Supports additional claims in JWT tokens (#171) + * Adds makeHttpClient for creating authorized Guzzle clients (#162) + * Misc bug fixes/improvements (#168, #161, #167, #170, #143) + +## 1.0.1 (31/07/2017) + +### Changes + +* Adds support for Firebase 5.0 (#159) + +## 1.0.0 (12/06/2017) + +### Changes + +* Adds hashing and shortening to enforce max key length ([@bshaffer]) +* Fix for better PSR-6 compliance - verifies a hit before getting the cache item ([@bshaffer]) +* README fixes ([@bshaffer]) +* Change authorization header key to lowercase ([@stanley-cheung]) + +## 0.4.0 (23/04/2015) + +### Changes + +* Export callback function to update auth metadata ([@stanley-cheung][]) +* Adds an implementation of User Refresh Token auth ([@stanley-cheung][]) + +[@bshaffer]: https://github.com/bshaffer +[@stanley-cheung]: https://github.com/stanley-cheung diff --git a/Auth/CODE_OF_CONDUCT.md b/Auth/CODE_OF_CONDUCT.md new file mode 100644 index 000000000000..46b2a08ea6d1 --- /dev/null +++ b/Auth/CODE_OF_CONDUCT.md @@ -0,0 +1,43 @@ +# Contributor Code of Conduct + +As contributors and maintainers of this project, +and in the interest of fostering an open and welcoming community, +we pledge to respect all people who contribute through reporting issues, +posting feature requests, updating documentation, +submitting pull requests or patches, and other activities. + +We are committed to making participation in this project +a harassment-free experience for everyone, +regardless of level of experience, gender, gender identity and expression, +sexual orientation, disability, personal appearance, +body size, race, ethnicity, age, religion, or nationality. + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery +* Personal attacks +* Trolling or insulting/derogatory comments +* Public or private harassment +* Publishing other's private information, +such as physical or electronic +addresses, without explicit permission +* Other unethical or unprofessional conduct. + +Project maintainers have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct. +By adopting this Code of Conduct, +project maintainers commit themselves to fairly and consistently +applying these principles to every aspect of managing this project. +Project maintainers who do not follow or enforce the Code of Conduct +may be permanently removed from the project team. + +This code of conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. + +Instances of abusive, harassing, or otherwise unacceptable behavior +may be reported by opening an issue +or contacting one or more of the project maintainers. + +This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.2.0, +available at [http://contributor-covenant.org/version/1/2/0/](http://contributor-covenant.org/version/1/2/0/) diff --git a/Auth/CONTRIBUTING.md b/Auth/CONTRIBUTING.md new file mode 100644 index 000000000000..5cb644f2d986 --- /dev/null +++ b/Auth/CONTRIBUTING.md @@ -0,0 +1,73 @@ +# How to become a contributor and submit your own code + +## Contributor License Agreements + +We'd love to accept your sample apps and patches! Before we can take them, we +have to jump a couple of legal hurdles. + +Please fill out either the individual or corporate Contributor License Agreement +(CLA). + + * If you are an individual writing original source code and you're sure you + own the intellectual property, then you'll need to sign an [individual CLA] + (http://code.google.com/legal/individual-cla-v1.0.html). + * If you work for a company that wants to allow you to contribute your work, + then you'll need to sign a [corporate CLA] + (http://code.google.com/legal/corporate-cla-v1.0.html). + +Follow either of the two links above to access the appropriate CLA and +instructions for how to sign and return it. Once we receive it, we'll be able to +accept your pull requests. + +## Issue reporting + +* Check that the issue has not already been reported. +* Check that the issue has not already been fixed in the latest code + (a.k.a. `main`). +* Be clear, concise and precise in your description of the problem. +* Open an issue with a descriptive title and a summary in grammatically correct, + complete sentences. +* Include any relevant code to the issue summary. + +## Pull requests + +* Read [how to properly contribute to open source projects on Github][2]. +* Fork the project. +* Use a topic/feature branch to easily amend a pull request later, if necessary. +* Write [good commit messages][3]. +* Use the same coding conventions as the rest of the project. +* Commit and push until you are happy with your contribution. +* Make sure to add tests for it. This is important so I don't break it + in a future version unintentionally. +* Add an entry to the [Changelog](CHANGELOG.md) accordingly. See [changelog entry format](#changelog-entry-format). +* Please try not to mess with the Rakefile, version, or history. If you want to + have your own version, or is otherwise necessary, that is fine, but please + isolate to its own commit so I can cherry-pick around it. +* Make sure the test suite is passing and the code you wrote doesn't produce + phpunit or phplint offenses. +* [Squash related commits together][5]. +* Open a [pull request][4] that relates to *only* one subject with a clear title + and description in grammatically correct, complete sentences. + +### Changelog entry format + +Here are a few examples: + +``` +* ADC Support for User Refresh Tokens (@tbetbetbe[]) +* [#16](https://github.com/google/google-auth-library-php/issues/16): ADC Support for User Refresh Tokens ([@tbetbetbe][]) +``` + +* Mark it up in [Markdown syntax][6]. +* The entry line should start with `* ` (an asterisk and a space). +* If the change has a related GitHub issue (e.g. a bug fix for a reported issue), put a link to the issue as `[#16](https://github.com/google/google-auth-library-php/issues/16): `. +* Describe the brief of the change. The sentence should end with a punctuation. +* At the end of the entry, add an implicit link to your GitHub user page as `([@username][])`. +* If this is your first contribution to google-auth-library-php project, add a link definition for the implicit link to the bottom of the changelog as `[@username]: https://github.com/username`. + +[1]: https://github.com/google/google-auth-php-library/issues +[2]: http://gun.io/blog/how-to-github-fork-branch-and-pull-request +[3]: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html +[4]: https://help.github.com/articles/using-pull-requests +[5]: http://gitready.com/advanced/2009/02/10/squashing-commits-with-rebase.html +[6]: http://daringfireball.net/projects/markdown/syntax diff --git a/Auth/COPYING b/Auth/COPYING new file mode 100644 index 000000000000..b5d5055a2ee6 --- /dev/null +++ b/Auth/COPYING @@ -0,0 +1,202 @@ + + 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 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. diff --git a/Auth/LICENSE b/Auth/LICENSE new file mode 100644 index 000000000000..a148ba564bf2 --- /dev/null +++ b/Auth/LICENSE @@ -0,0 +1,203 @@ +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/Auth/README.md b/Auth/README.md new file mode 100644 index 000000000000..6dbd64d8ba25 --- /dev/null +++ b/Auth/README.md @@ -0,0 +1,374 @@ +# Google Auth Library for PHP + +* [API documentation](https://cloud.google.com/php/docs/reference/auth/latest) + +**NOTE:** This repository is part of [Google Cloud PHP](https://github.com/googleapis/google-cloud-php). Any +support requests, bug reports, or development contributions should be directed to +that project. + +## Description + +This is Google's officially supported PHP client library for using OAuth 2.0 +authorization and authentication with Google APIs. + +### Installing via Composer + +The recommended way to install the google auth library is through +[Composer](http://getcomposer.org). + +```bash +# Install Composer +curl -sS https://getcomposer.org/installer | php +``` + +Next, run the Composer command to install the latest stable version: + +```bash +composer.phar require google/auth +``` + +## Application Default Credentials + +This library provides an implementation of +[Application Default Credentials (ADC)][application default credentials] for PHP. + +Application Default Credentials provides a simple way to get authorization +credentials for use in calling Google APIs, and is +the recommended approach to authorize calls to Cloud APIs. + +**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 library. Providing an unvalidated credential configuration to Google APIs +can compromise the security of your systems and data. For more information, refer to +[Validate credential configurations from external sources][externally-sourced-credentials]. + +[externally-sourced-credentials]: https://cloud.google.com/docs/authentication/external/externally-sourced-credentials + +### Set up ADC + +To use ADC, you must set it up by providing credentials. +How you set up ADC depends on the environment where your code is running, +and whether you are running code in a test or production environment. + +For more information, see [Set up Application Default Credentials][set-up-adc]. + +### Enable the API you want to use + +Before making your API call, you must be sure the API you're calling has been +enabled. Go to **APIs & Auth** > **APIs** in the +[Google Developers Console][developer console] and enable the APIs you'd like to +call. For the example below, you must enable the `Drive API`. + +### Call the APIs + +As long as you update the environment variable below to point to *your* JSON +credentials file, the following code should output a list of your Drive files. + +```php +use Google\Auth\ApplicationDefaultCredentials; +use GuzzleHttp\Client; +use GuzzleHttp\HandlerStack; + +// specify the path to your application credentials +putenv('GOOGLE_APPLICATION_CREDENTIALS=/path/to/my/credentials.json'); + +// define the scopes for your API call +$scopes = ['https://www.googleapis.com/auth/drive.readonly']; + +// create middleware +$middleware = ApplicationDefaultCredentials::getMiddleware($scopes); +$stack = HandlerStack::create(); +$stack->push($middleware); + +// create the HTTP client +$client = new Client([ + 'handler' => $stack, + 'base_uri' => 'https://www.googleapis.com', + 'auth' => 'google_auth' // authorize all requests +]); + +// make the request +$response = $client->get('drive/v2/files'); + +// show the result! +print_r((string) $response->getBody()); +``` + +##### Guzzle 5 Compatibility + +If you are using [Guzzle 5][Guzzle 5], replace the `create middleware` and +`create the HTTP Client` steps with the following: + +```php +// create the HTTP client +$client = new Client([ + 'base_url' => 'https://www.googleapis.com', + 'auth' => 'google_auth' // authorize all requests +]); + +// create subscriber +$subscriber = ApplicationDefaultCredentials::getSubscriber($scopes); +$client->getEmitter()->attach($subscriber); +``` + +#### Call using an ID Token +If your application is running behind Cloud Run, or using Cloud Identity-Aware +Proxy (IAP), you will need to fetch an ID token to access your application. For +this, use the static method `getIdTokenMiddleware` on +`ApplicationDefaultCredentials`. + +```php +use Google\Auth\ApplicationDefaultCredentials; +use GuzzleHttp\Client; +use GuzzleHttp\HandlerStack; + +// specify the path to your application credentials +putenv('GOOGLE_APPLICATION_CREDENTIALS=/path/to/my/credentials.json'); + +// Provide the ID token audience. This can be a Client ID associated with an IAP application, +// Or the URL associated with a CloudRun App +// $targetAudience = 'IAP_CLIENT_ID.apps.googleusercontent.com'; +// $targetAudience = 'https://service-1234-uc.a.run.app'; +$targetAudience = 'YOUR_ID_TOKEN_AUDIENCE'; + +// create middleware +$middleware = ApplicationDefaultCredentials::getIdTokenMiddleware($targetAudience); +$stack = HandlerStack::create(); +$stack->push($middleware); + +// create the HTTP client +$client = new Client([ + 'handler' => $stack, + 'auth' => 'google_auth', + // Cloud Run, IAP, or custom resource URL + 'base_uri' => 'https://YOUR_PROTECTED_RESOURCE', +]); + +// make the request +$response = $client->get('/'); + +// show the result! +print_r((string) $response->getBody()); +``` + +For invoking Cloud Run services, your service account will need the +[`Cloud Run Invoker`](https://cloud.google.com/run/docs/authenticating/service-to-service) +IAM permission. + +For invoking Cloud Identity-Aware Proxy, you will need to pass the Client ID +used when you set up your protected resource as the target audience. See how to +[secure your IAP app with signed headers](https://cloud.google.com/iap/docs/signed-headers-howto). + +#### Call using a specific JSON key +If you want to use a specific JSON key instead of using `GOOGLE_APPLICATION_CREDENTIALS` environment variable, you can + do this: + +```php +use Google\Auth\Credentials\ServiceAccountCredentials; +use Google\Auth\Credentials\UserRefreshCredentials; +use Google\Auth\FetchAuthTokenCache; +use Google\Auth\Middleware\AuthTokenMiddleware; +use GuzzleHttp\Client; +use GuzzleHttp\HandlerStack; + +// Define the Google Application Credentials array +$jsonKey = ['key' => 'value']; + +// define the scopes for your API call +$scopes = ['https://www.googleapis.com/auth/drive.readonly']; + +// Load credentials from JSON containing service account credentials. +$creds = new ServiceAccountCredentials($scopes, $jsonKey); + +// For other credentials types, create those classes explicitly using the +// "type" field in the JSON key, for example: +$creds = match ($jsonKey['type']) { + 'service_account' => new ServiceAccountCredentials($scopes, $jsonKey), + 'authorized_user' => new UserRefreshCredentials($scopes, $jsonKey), + default => throw new InvalidArgumentException('This application only supports service account and user account credentials'), +}; + +// optional caching +$creds = new FetchAuthTokenCache($creds, $cacheConfig, $cache); + +// create middleware +$middleware = new AuthTokenMiddleware($creds); +$stack = HandlerStack::create(); +$stack->push($middleware); + +// create the HTTP client +$client = new Client([ + 'handler' => $stack, + 'base_uri' => 'https://www.googleapis.com', + 'auth' => 'google_auth' // authorize all requests +]); + +// make the request +$response = $client->get('drive/v2/files'); + +// show the result! +print_r((string) $response->getBody()); + +``` + +#### Call using Proxy-Authorization Header +If your application is behind a proxy such as [Google Cloud IAP][iap-proxy-header], +and your application occupies the `Authorization` request header, +you can include the ID token in a `Proxy-Authorization: Bearer` +header instead. If a valid ID token is found in a `Proxy-Authorization` header, +IAP authorizes the request with it. After authorizing the request, IAP passes +the Authorization header to your application without processing the content. +For this, use the static method `getProxyIdTokenMiddleware` on +`ApplicationDefaultCredentials`. + +```php +use Google\Auth\ApplicationDefaultCredentials; +use GuzzleHttp\Client; +use GuzzleHttp\HandlerStack; + +// specify the path to your application credentials +putenv('GOOGLE_APPLICATION_CREDENTIALS=/path/to/my/credentials.json'); + +// Provide the ID token audience. This can be a Client ID associated with an IAP application +// $targetAudience = 'IAP_CLIENT_ID.apps.googleusercontent.com'; +$targetAudience = 'YOUR_ID_TOKEN_AUDIENCE'; + +// create middleware +$middleware = ApplicationDefaultCredentials::getProxyIdTokenMiddleware($targetAudience); +$stack = HandlerStack::create(); +$stack->push($middleware); + +// create the HTTP client +$client = new Client([ + 'handler' => $stack, + 'auth' => ['username', 'pass'], // auth option handled by your application + 'proxy_auth' => 'google_auth', +]); + +// make the request +$response = $client->get('/'); + +// show the result! +print_r((string) $response->getBody()); +``` + +[iap-proxy-header]: https://cloud.google.com/iap/docs/authentication-howto#authenticating_from_proxy-authorization_header + +#### External credentials (Workload identity federation) + +Using workload identity federation, your application can access Google Cloud resources from Amazon Web Services (AWS), +Microsoft Azure or any identity provider that supports OpenID Connect (OIDC). + +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. + +Follow the detailed instructions on how to +[Configure Workload Identity Federation](https://cloud.google.com/iam/docs/workload-identity-federation-with-other-clouds). + +#### Verifying JWTs + +If you are [using Google ID tokens to authenticate users][google-id-tokens], use +the `Google\Auth\AccessToken` class to verify the ID token: + +```php +use Google\Auth\AccessToken; + +$auth = new AccessToken(); +$auth->verify($idToken); +``` + +If your app is running behind [Google Identity-Aware Proxy][iap-id-tokens] +(IAP), you can verify the ID token coming from the IAP server by pointing to the +appropriate certificate URL for IAP. This is because IAP signs the ID +tokens with a different key than the Google Identity service: + +```php +use Google\Auth\AccessToken; + +$auth = new AccessToken(); +$auth->verify($idToken, [ + 'certsLocation' => AccessToken::IAP_CERT_URL +]); +``` + +[google-id-tokens]: https://developers.google.com/identity/sign-in/web/backend-auth +[iap-id-tokens]: https://cloud.google.com/iap/docs/signed-headers-howto + +## Caching +Caching is enabled by passing a PSR-6 `CacheItemPoolInterface` +instance to the constructor when instantiating the credentials. + +We offer some caching classes out of the box under the `Google\Auth\Cache` namespace. + +```php +use Google\Auth\ApplicationDefaultCredentials; +use Google\Auth\Cache\MemoryCacheItemPool; + +// Cache Instance +$memoryCache = new MemoryCacheItemPool; + +// Get the credentials +// From here, the credentials will cache the access token +$middleware = ApplicationDefaultCredentials::getCredentials($scope, cache: $memoryCache); +``` + +### FileSystemCacheItemPool Cache +The `FileSystemCacheItemPool` class is a `PSR-6` compliant cache that stores its +serialized objects on disk, caching data between processes and making it possible +to use data between different requests. + +```php +use Google\Auth\Cache\FileSystemCacheItemPool; +use Google\Auth\ApplicationDefaultCredentials; + +// Create a Cache pool instance +$cache = new FileSystemCacheItemPool(__DIR__ . '/cache'); + +// Pass your Cache to the Auth Library +$credentials = ApplicationDefaultCredentials::getCredentials($scope, cache: $cache); + +// This token will be cached and be able to be used for the next request +$token = $credentials->fetchAuthToken(); +``` + +### Integrating with a third party cache +You can use a third party that follows the `PSR-6` interface of your choice. + +```php +// run "composer require symfony/cache" +use Google\Auth\ApplicationDefaultCredentials; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; + +// Create the cache instance +$filesystemCache = new FilesystemAdapter(); + +// Create Get the credentials +$credentials = ApplicationDefaultCredentials::getCredentials($targetAudience, cache: $filesystemCache); +``` + +## License + +This library is licensed under Apache 2.0. Full license text is +available in [COPYING][copying]. + +## Contributing + +See [CONTRIBUTING][contributing]. + +## Support + +Please +[report bugs at the project on Github](https://github.com/google/google-auth-library-php/issues). Don't +hesitate to +[ask questions](http://stackoverflow.com/questions/tagged/google-auth-library-php) +about the client or APIs on [StackOverflow](http://stackoverflow.com). + +[google-apis-php-client]: https://github.com/google/google-api-php-client +[application default credentials]: https://cloud.google.com/docs/authentication/application-default-credentials +[contributing]: https://github.com/google/google-auth-library-php/tree/main/.github/CONTRIBUTING.md +[copying]: https://github.com/google/google-auth-library-php/tree/main/COPYING +[Guzzle]: https://github.com/guzzle/guzzle +[Guzzle 5]: http://docs.guzzlephp.org/en/5.3 +[developer console]: https://console.developers.google.com +[set-up-adc]: https://cloud.google.com/docs/authentication/provide-credentials-adc diff --git a/Auth/SECURITY.md b/Auth/SECURITY.md new file mode 100644 index 000000000000..8b58ae9c01ae --- /dev/null +++ b/Auth/SECURITY.md @@ -0,0 +1,7 @@ +# Security Policy + +To report a security issue, please use [g.co/vulnz](https://g.co/vulnz). + +The Google Security Team will respond within 5 working days of your report on g.co/vulnz. + +We use g.co/vulnz for our intake, and do coordination and disclosure here using GitHub Security Advisory to privately discuss and fix the issue. diff --git a/Auth/VERSION b/Auth/VERSION new file mode 100644 index 000000000000..3f4830156cbe --- /dev/null +++ b/Auth/VERSION @@ -0,0 +1 @@ +1.53.0 diff --git a/Auth/composer.json b/Auth/composer.json new file mode 100644 index 000000000000..ab5c5d493696 --- /dev/null +++ b/Auth/composer.json @@ -0,0 +1,54 @@ +{ + "name": "google/auth", + "type": "library", + "description": "Google Auth Library for PHP", + "keywords": ["google", "oauth2", "authentication"], + "homepage": "https://github.com/googleapis/google-auth-library-php", + "license": "Apache-2.0", + "support": { + "docs": "https://cloud.google.com/php/docs/reference/auth/latest" + }, + "require": { + "php": "^8.1", + "firebase/php-jwt": "^6.0||^7.0", + "guzzlehttp/guzzle": "^7.8.2||^8.0", + "guzzlehttp/psr7": "^2.6.3||^3.0", + "psr/http-client": "^1.0", + "psr/http-message": "^1.1||^2.0", + "psr/cache": "^2.0||^3.0", + "psr/log": "^2.0||^3.0" + }, + "require-dev": { + "guzzlehttp/promises": "^2.0.3||^3.0", + "squizlabs/php_codesniffer": "^4.0", + "phpunit/phpunit": "^9.6", + "phpspec/prophecy-phpunit": "^2.1", + "sebastian/comparator": ">=1.2.3", + "phpseclib/phpseclib": "^3.0.35", + "kelvinmo/simplejwt": "^1.1.0", + "webmozart/assert": "^1.11||^2.0", + "symfony/process": "^6.0||^7.0", + "symfony/filesystem": "^6.3||^7.3" + }, + "suggest": { + "phpseclib/phpseclib": "May be used in place of OpenSSL for signing strings or for token management. Please require version ^2." + }, + "autoload": { + "psr-4": { + "Google\\Auth\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Google\\Auth\\Tests\\": "tests" + } + }, + "extra": { + "component": { + "id": "auth", + "target": "googleapis/google-auth-library-php.git", + "path": "Auth", + "entry": "README.md" + } + } +} diff --git a/Auth/phpstan.neon.dist b/Auth/phpstan.neon.dist new file mode 100644 index 000000000000..77b4b266957b --- /dev/null +++ b/Auth/phpstan.neon.dist @@ -0,0 +1,5 @@ +parameters: + treatPhpDocTypesAsCertain: false + level: 7 + paths: + - src diff --git a/Auth/phpunit.xml.dist b/Auth/phpunit.xml.dist new file mode 100644 index 000000000000..2e2253269177 --- /dev/null +++ b/Auth/phpunit.xml.dist @@ -0,0 +1,16 @@ + + + + + src + + + src/ + + + + + tests + + + diff --git a/Auth/src/AccessToken.php b/Auth/src/AccessToken.php new file mode 100644 index 000000000000..9e27b692ed8b --- /dev/null +++ b/Auth/src/AccessToken.php @@ -0,0 +1,473 @@ +httpHandler = $httpHandler + ?: HttpHandlerFactory::build(HttpClientCache::getHttpClient()); + $this->cache = $cache ?: new MemoryCacheItemPool(); + } + + /** + * Verifies an id token and returns the authenticated apiLoginTicket. + * Throws an exception if the id token is not valid. + * The audience parameter can be used to control which id tokens are + * accepted. By default, the id token must have been issued to this OAuth2 client. + * + * @param string $token The JSON Web Token to be verified. + * @param array $options [optional] { + * Configuration options. + * @type string $audience The indended recipient of the token. + * @type string $issuer The intended issuer of the token. + * @type string $cacheKey The cache key of the cached certs. Defaults to + * the sha1 of $certsLocation if provided, otherwise is set to + * "federated_signon_certs_v3". + * @type string $certsLocation The location (remote or local) from which + * to retrieve certificates, if not cached. This value should only be + * provided in limited circumstances in which you are sure of the + * behavior. + * @type bool $throwException Whether the function should throw an + * exception if the verification fails. This is useful for + * determining the reason verification failed. + * } + * @return array|false the token payload, if successful, or false if not. + * @throws InvalidArgumentException If certs could not be retrieved from a local file. + * @throws InvalidArgumentException If received certs are in an invalid format. + * @throws InvalidArgumentException If the cert alg is not supported. + * @throws RuntimeException If certs could not be retrieved from a remote location. + * @throws UnexpectedValueException If the token issuer does not match. + * @throws UnexpectedValueException If the token audience does not match. + */ + public function verify($token, array $options = []) + { + $audience = $options['audience'] ?? null; + $issuer = $options['issuer'] ?? null; + $certsLocation = $options['certsLocation'] ?? self::FEDERATED_SIGNON_CERT_URL; + $cacheKey = $options['cacheKey'] ?? $this->getCacheKeyFromCertLocation($certsLocation); + $throwException = $options['throwException'] ?? false; // for backwards compatibility + + // Check signature against each available cert. + $certs = $this->getCerts($certsLocation, $cacheKey, $options); + $alg = $this->determineAlg($certs); + if (!in_array($alg, ['RS256', 'ES256'])) { + throw new InvalidArgumentException( + 'unrecognized "alg" in certs, expected ES256 or RS256' + ); + } + try { + if ($alg == 'RS256') { + return $this->verifyRs256($token, $certs, $audience, $issuer); + } + return $this->verifyEs256($token, $certs, $audience, $issuer); + } catch (ExpiredException $e) { // firebase/php-jwt 5+ + } catch (SignatureInvalidException $e) { // firebase/php-jwt 5+ + } catch (InvalidTokenException $e) { // simplejwt + } catch (InvalidArgumentException $e) { + } catch (UnexpectedValueException $e) { + } + + if ($throwException) { + throw $e; + } + + return false; + } + + /** + * Identifies the expected algorithm to verify by looking at the "alg" key + * of the provided certs. + * + * @param array $certs Certificate array according to the JWK spec (see + * https://tools.ietf.org/html/rfc7517). + * @return string The expected algorithm, such as "ES256" or "RS256". + */ + private function determineAlg(array $certs) + { + $alg = null; + foreach ($certs as $cert) { + if (empty($cert['alg'])) { + throw new InvalidArgumentException( + 'certs expects "alg" to be set' + ); + } + $alg = $alg ?: $cert['alg']; + + if ($alg != $cert['alg']) { + throw new InvalidArgumentException( + 'More than one alg detected in certs' + ); + } + } + return $alg; + } + + /** + * Verifies an ES256-signed JWT. + * + * @param string $token The JSON Web Token to be verified. + * @param array $certs Certificate array according to the JWK spec (see + * https://tools.ietf.org/html/rfc7517). + * @param string|null $audience If set, returns false if the provided + * audience does not match the "aud" claim on the JWT. + * @param string|null $issuer If set, returns false if the provided + * issuer does not match the "iss" claim on the JWT. + * @return array the token payload, if successful, or false if not. + */ + private function verifyEs256($token, array $certs, $audience = null, $issuer = null) + { + $this->checkSimpleJwt(); + + $jwkset = new KeySet(); + foreach ($certs as $cert) { + $jwkset->add(KeyFactory::create($cert, 'php')); + } + + // Validate the signature using the key set and ES256 algorithm. + $jwt = $this->callSimpleJwtDecode([$token, $jwkset, 'ES256']); + $payload = $jwt->getClaims(); + + if ($audience) { + if (!isset($payload['aud']) || $payload['aud'] != $audience) { + throw new UnexpectedValueException('Audience does not match'); + } + } + + // @see https://cloud.google.com/iap/docs/signed-headers-howto#verifying_the_jwt_payload + $issuer = $issuer ?: self::IAP_ISSUER; + if (!isset($payload['iss']) || $payload['iss'] !== $issuer) { + throw new UnexpectedValueException('Issuer does not match'); + } + + return $payload; + } + + /** + * Verifies an RS256-signed JWT. + * + * @param string $token The JSON Web Token to be verified. + * @param array $certs Certificate array according to the JWK spec (see + * https://tools.ietf.org/html/rfc7517). + * @param string|null $audience If set, returns false if the provided + * audience does not match the "aud" claim on the JWT. + * @param string|null $issuer If set, returns false if the provided + * issuer does not match the "iss" claim on the JWT. + * @return array the token payload, if successful, or false if not. + */ + private function verifyRs256($token, array $certs, $audience = null, $issuer = null) + { + $this->checkAndInitializePhpsec(); + $keys = []; + foreach ($certs as $cert) { + if (empty($cert['kid'])) { + throw new InvalidArgumentException( + 'certs expects "kid" to be set' + ); + } + if (empty($cert['n']) || empty($cert['e'])) { + throw new InvalidArgumentException( + 'RSA certs expects "n" and "e" to be set' + ); + } + $publicKey = $this->loadPhpsecPublicKey($cert['n'], $cert['e']); + + // create an array of key IDs to certs for the JWT library + $keys[$cert['kid']] = new Key($publicKey, 'RS256'); + } + + $payload = $this->callJwtStatic('decode', [ + $token, + $keys, + ]); + + if ($audience) { + if (!property_exists($payload, 'aud') || $payload->aud != $audience) { + throw new UnexpectedValueException('Audience does not match'); + } + } + + // support HTTP and HTTPS issuers + // @see https://developers.google.com/identity/sign-in/web/backend-auth + $issuers = $issuer ? [$issuer] : [self::OAUTH2_ISSUER, self::OAUTH2_ISSUER_HTTPS]; + if (!isset($payload->iss) || !in_array($payload->iss, $issuers)) { + throw new UnexpectedValueException('Issuer does not match'); + } + + return (array) $payload; + } + + /** + * Revoke an OAuth2 access token or refresh token. This method will revoke the current access + * token, if a token isn't provided. + * + * @param string|array $token The token (access token or a refresh token) that should be revoked. + * @param array $options [optional] Configuration options. + * @return bool Returns True if the revocation was successful, otherwise False. + */ + public function revoke($token, array $options = []) + { + if (is_array($token)) { + if (isset($token['refresh_token'])) { + $token = $token['refresh_token']; + } else { + $token = $token['access_token']; + } + } + + $body = Utils::streamFor(http_build_query(['token' => $token])); + $request = new Request('POST', self::OAUTH2_REVOKE_URI, [ + 'Cache-Control' => 'no-store', + 'Content-Type' => 'application/x-www-form-urlencoded', + ], $body); + + $httpHandler = $this->httpHandler; + + $response = $httpHandler($request, $options); + + return $response->getStatusCode() == 200; + } + + /** + * Gets federated sign-on certificates to use for verifying identity tokens. + * Returns certs as array structure, where keys are key ids, and values + * are PEM encoded certificates. + * + * @param string $location The location from which to retrieve certs. + * @param string $cacheKey The key under which to cache the retrieved certs. + * @param array $options [optional] Configuration options. + * @return array + * @throws InvalidArgumentException If received certs are in an invalid format. + */ + private function getCerts($location, $cacheKey, array $options = []) + { + $cacheItem = $this->cache->getItem($cacheKey); + $certs = $cacheItem ? $cacheItem->get() : null; + + $expireTime = null; + if (!$certs) { + list($certs, $expireTime) = $this->retrieveCertsFromLocation($location, $options); + } + + if (!isset($certs['keys'])) { + if ($location !== self::IAP_CERT_URL) { + throw new InvalidArgumentException( + 'federated sign-on certs expects "keys" to be set' + ); + } + throw new InvalidArgumentException( + 'certs expects "keys" to be set' + ); + } + + // Push caching off until after verifying certs are in a valid format. + // Don't want to cache bad data. + if ($expireTime) { + $cacheItem->expiresAt(new DateTime($expireTime)); + $cacheItem->set($certs); + $this->cache->save($cacheItem); + } + + return $certs['keys']; + } + + /** + * Retrieve and cache a certificates file. + * + * @param string $url location + * @param array $options [optional] Configuration options. + * @return array{array, string} + * @throws InvalidArgumentException If certs could not be retrieved from a local file. + * @throws RuntimeException If certs could not be retrieved from a remote location. + */ + private function retrieveCertsFromLocation($url, array $options = []) + { + // If we're retrieving a local file, just grab it. + $expireTime = '+1 hour'; + if (strpos($url, 'http') !== 0) { + if (!file_exists($url)) { + throw new InvalidArgumentException(sprintf( + 'Failed to retrieve verification certificates from path: %s.', + $url + )); + } + + return [ + json_decode((string) file_get_contents($url), true), + $expireTime + ]; + } + + $httpHandler = $this->httpHandler; + $response = $httpHandler(new Request('GET', $url), $options); + + if ($response->getStatusCode() == 200) { + if ($cacheControl = $response->getHeaderLine('Cache-Control')) { + array_map(function ($value) use (&$expireTime) { + list($key, $value) = explode('=', $value) + [null, null]; + if (trim($key) == 'max-age') { + $expireTime = '+' . $value . ' seconds'; + } + }, explode(',', $cacheControl)); + } + return [ + json_decode((string) $response->getBody(), true), + $expireTime + ]; + } + + throw new RuntimeException(sprintf( + 'Failed to retrieve verification certificates: "%s".', + $response->getBody()->getContents() + ), $response->getStatusCode()); + } + + /** + * @return void + */ + private function checkAndInitializePhpsec() + { + if (!class_exists(RSA::class)) { + throw new RuntimeException('Please require phpseclib/phpseclib v3 to use this utility.'); + } + } + + /** + * @return string + * @throws TypeError If the key cannot be initialized to a string. + */ + private function loadPhpsecPublicKey(string $modulus, string $exponent): string + { + $key = PublicKeyLoader::load([ + 'n' => new BigInteger($this->callJwtStatic('urlsafeB64Decode', [ + $modulus, + ]), 256), + 'e' => new BigInteger($this->callJwtStatic('urlsafeB64Decode', [ + $exponent + ]), 256), + ]); + $formattedPublicKey = $key->toString('PKCS8'); + if (!is_string($formattedPublicKey)) { + throw new TypeError('Failed to initialize the key'); + } + return $formattedPublicKey; + } + + /** + * @return void + */ + private function checkSimpleJwt() + { + // @codeCoverageIgnoreStart + if (!class_exists(SimpleJwt::class)) { + throw new RuntimeException('Please require kelvinmo/simplejwt ^0.2 to use this utility.'); + } + // @codeCoverageIgnoreEnd + } + + /** + * Provide a hook to mock calls to the JWT static methods. + * + * @param string $method + * @param array $args + * @return mixed + */ + protected function callJwtStatic($method, array $args = []) + { + return call_user_func_array([JWT::class, $method], $args); // @phpstan-ignore-line + } + + /** + * Provide a hook to mock calls to the JWT static methods. + * + * @param array $args + * @return mixed + */ + protected function callSimpleJwtDecode(array $args = []) + { + return call_user_func_array([SimpleJwt::class, 'decode'], $args); + } + + /** + * Generate a cache key based on the cert location using sha1 with the + * exception of using "federated_signon_certs_v3" to preserve BC. + * + * @param string $certsLocation + * @return string + */ + private function getCacheKeyFromCertLocation($certsLocation) + { + $key = $certsLocation === self::FEDERATED_SIGNON_CERT_URL + ? 'federated_signon_certs_v3' + : sha1($certsLocation); + + return 'google_auth_certs_cache|' . $key; + } +} diff --git a/Auth/src/ApplicationDefaultCredentials.php b/Auth/src/ApplicationDefaultCredentials.php new file mode 100644 index 000000000000..fc3eca027437 --- /dev/null +++ b/Auth/src/ApplicationDefaultCredentials.php @@ -0,0 +1,411 @@ +push($middleware); + * + * $client = new Client([ + * 'handler' => $stack, + * 'base_uri' => 'https://www.googleapis.com/taskqueue/v1beta2/projects/', + * 'auth' => 'google_auth' // authorize all requests + * ]); + * + * $res = $client->get('myproject/taskqueues/myqueue'); + * ``` + */ +class ApplicationDefaultCredentials +{ + private const SDK_DEBUG_ENV_VAR = 'GOOGLE_SDK_PHP_LOGGING'; + + /** + * + * Obtains an AuthTokenSubscriber that uses the default FetchAuthTokenInterface + * implementation to use in this environment. + * + * If supplied, $scope is used to in creating the credentials instance if + * this does not fallback to the compute engine defaults. + * + * @deprecated + * @param string|string[] $scope the scope of the access request, expressed + * either as an Array or as a space-delimited String. + * @param callable|null $httpHandler callback which delivers psr7 request + * @param array|null $cacheConfig configuration for the cache when it's present + * @param CacheItemPoolInterface|null $cache A cache implementation, may be + * provided if you have one already available for use. + * @return AuthTokenSubscriber + * @throws DomainException if no implementation can be obtained. + */ + public static function getSubscriber(// @phpstan-ignore-line + $scope = null, + ?callable $httpHandler = null, + ?array $cacheConfig = null, + ?CacheItemPoolInterface $cache = null + ) { + $creds = self::getCredentials($scope, $httpHandler, $cacheConfig, $cache); + + /** @phpstan-ignore-next-line */ + return new AuthTokenSubscriber($creds, $httpHandler); + } + + /** + * Obtains an AuthTokenMiddleware that uses the default FetchAuthTokenInterface + * implementation to use in this environment. + * + * If supplied, $scope is used to in creating the credentials instance if + * this does not fallback to the compute engine defaults. + * + * @param string|string[] $scope the scope of the access request, expressed + * either as an Array or as a space-delimited String. + * @param callable|null $httpHandler callback which delivers psr7 request + * @param array|null $cacheConfig configuration for the cache when it's present + * @param CacheItemPoolInterface|null $cache A cache implementation, may be + * provided if you have one already available for use. + * @param string $quotaProject specifies a project to bill for access + * charges associated with the request. + * @return AuthTokenMiddleware + * @throws DomainException if no implementation can be obtained. + */ + public static function getMiddleware( + $scope = null, + ?callable $httpHandler = null, + ?array $cacheConfig = null, + ?CacheItemPoolInterface $cache = null, + $quotaProject = null + ) { + $creds = self::getCredentials($scope, $httpHandler, $cacheConfig, $cache, $quotaProject); + + return new AuthTokenMiddleware($creds, $httpHandler); + } + + /** + * Obtains the default FetchAuthTokenInterface implementation to use + * in this environment. + * + * @param string|string[] $scope the scope of the access request, expressed + * either as an Array or as a space-delimited String. + * @param callable|null $httpHandler callback which delivers psr7 request + * @param array|null $cacheConfig configuration for the cache when it's present + * @param CacheItemPoolInterface|null $cache A cache implementation, may be + * provided if you have one already available for use. + * @param string|null $quotaProject specifies a project to bill for access + * charges associated with the request. + * @param string|string[]|null $defaultScope The default scope to use if no + * user-defined scopes exist, expressed either as an Array or as a + * space-delimited string. + * @param string|null $universeDomain Specifies a universe domain to use for the + * calling client library. + * @param null|false|LoggerInterface $logger A PSR3 compliant LoggerInterface. + * @param bool $enableRegionalAccessBoundary Lookup and include the regional access boundary header. + * + * @return FetchAuthTokenInterface + * @throws DomainException if no implementation can be obtained. + */ + public static function getCredentials( + $scope = null, + ?callable $httpHandler = null, + ?array $cacheConfig = null, + ?CacheItemPoolInterface $cache = null, + $quotaProject = null, + $defaultScope = null, + ?string $universeDomain = null, + null|false|LoggerInterface $logger = null, + bool $enableRegionalAccessBoundary = false + ) { + $creds = null; + $jsonKey = CredentialsLoader::fromEnv() + ?: CredentialsLoader::fromWellKnownFile(); + $anyScope = $scope ?: $defaultScope; + + if (!$httpHandler) { + if (!($client = HttpClientCache::getHttpClient())) { + $client = new Client(); + HttpClientCache::setHttpClient($client); + } + + $httpHandler = HttpHandlerFactory::build($client, $logger); + } + + if (is_null($quotaProject)) { + // if a quota project isn't specified, try to get one from the env var + $quotaProject = CredentialsLoader::quotaProjectFromEnv(); + } + + if (!is_null($jsonKey)) { + if ($quotaProject) { + $jsonKey['quota_project_id'] = $quotaProject; + } + if ($universeDomain) { + $jsonKey['universe_domain'] = $universeDomain; + } + $creds = CredentialsLoader::makeCredentials( + $scope, + $jsonKey, + $defaultScope, + $enableRegionalAccessBoundary + ); + } elseif (AppIdentityCredentials::onAppEngine() && !GCECredentials::onAppEngineFlexible()) { + $creds = new AppIdentityCredentials($anyScope); + } elseif (self::onGce($httpHandler, $cacheConfig, $cache)) { + $creds = new GCECredentials( + scope: $anyScope, + quotaProject: $quotaProject, + universeDomain: $universeDomain, + enableRegionalAccessBoundary: $enableRegionalAccessBoundary, + ); + $creds->setIsOnGce(true); // save the credentials a trip to the metadata server + } + + if (is_null($creds)) { + throw new DomainException(self::notFound()); + } + if (!is_null($cache)) { + $creds = new FetchAuthTokenCache($creds, $cacheConfig, $cache); + } + return $creds; + } + + /** + * Obtains an AuthTokenMiddleware which will fetch an ID token to use in the + * Authorization header. The middleware is configured with the default + * FetchAuthTokenInterface implementation to use in this environment. + * + * If supplied, $targetAudience is used to set the "aud" on the resulting + * ID token. + * + * @param string $targetAudience The audience for the ID token. + * @param callable|null $httpHandler callback which delivers psr7 request + * @param array|null $cacheConfig configuration for the cache when it's present + * @param CacheItemPoolInterface|null $cache A cache implementation, may be + * provided if you have one already available for use. + * @return AuthTokenMiddleware + * @throws DomainException if no implementation can be obtained. + */ + public static function getIdTokenMiddleware( + $targetAudience, + ?callable $httpHandler = null, + ?array $cacheConfig = null, + ?CacheItemPoolInterface $cache = null + ) { + $creds = self::getIdTokenCredentials($targetAudience, $httpHandler, $cacheConfig, $cache); + + return new AuthTokenMiddleware($creds, $httpHandler); + } + + /** + * Obtains an ProxyAuthTokenMiddleware which will fetch an ID token to use in the + * Authorization header. The middleware is configured with the default + * FetchAuthTokenInterface implementation to use in this environment. + * + * If supplied, $targetAudience is used to set the "aud" on the resulting + * ID token. + * + * @param string $targetAudience The audience for the ID token. + * @param callable|null $httpHandler callback which delivers psr7 request + * @param array|null $cacheConfig configuration for the cache when it's present + * @param CacheItemPoolInterface|null $cache A cache implementation, may be + * provided if you have one already available for use. + * @return ProxyAuthTokenMiddleware + * @throws DomainException if no implementation can be obtained. + */ + public static function getProxyIdTokenMiddleware( + $targetAudience, + ?callable $httpHandler = null, + ?array $cacheConfig = null, + ?CacheItemPoolInterface $cache = null + ) { + $creds = self::getIdTokenCredentials($targetAudience, $httpHandler, $cacheConfig, $cache); + + return new ProxyAuthTokenMiddleware($creds, $httpHandler); + } + + /** + * Obtains the default FetchAuthTokenInterface implementation to use + * in this environment, configured with a $targetAudience for fetching an ID + * token. + * + * @param string $targetAudience The audience for the ID token. + * @param callable|null $httpHandler callback which delivers psr7 request + * @param array|null $cacheConfig configuration for the cache when it's present + * @param CacheItemPoolInterface|null $cache A cache implementation, may be + * provided if you have one already available for use. + * @return FetchAuthTokenInterface + * @throws DomainException if no implementation can be obtained. + * @throws InvalidArgumentException if JSON "type" key is invalid + */ + public static function getIdTokenCredentials( + $targetAudience, + ?callable $httpHandler = null, + ?array $cacheConfig = null, + ?CacheItemPoolInterface $cache = null, + ) { + $creds = null; + $jsonKey = CredentialsLoader::fromEnv() + ?: CredentialsLoader::fromWellKnownFile(); + + if (!$httpHandler) { + if (!($client = HttpClientCache::getHttpClient())) { + $client = new Client(); + HttpClientCache::setHttpClient($client); + } + + $httpHandler = HttpHandlerFactory::build($client); + } + + if (!is_null($jsonKey)) { + if (!array_key_exists('type', $jsonKey)) { + throw new \InvalidArgumentException('json key is missing the type field'); + } + + $creds = match ($jsonKey['type']) { + 'authorized_user' => new UserRefreshCredentials(null, $jsonKey, $targetAudience), + 'impersonated_service_account' => new ImpersonatedServiceAccountCredentials( + scope: null, + jsonKey: $jsonKey, + targetAudience: $targetAudience, + ), + 'service_account' => new ServiceAccountCredentials( + scope: null, + jsonKey: $jsonKey, + targetAudience: $targetAudience, + ), + default => throw new InvalidArgumentException('invalid value in the type field') + }; + } elseif (self::onGce($httpHandler, $cacheConfig, $cache)) { + $creds = new GCECredentials(targetAudience: $targetAudience); + $creds->setIsOnGce(true); // save the credentials a trip to the metadata server + } + + if (is_null($creds)) { + throw new DomainException(self::notFound()); + } + if (!is_null($cache)) { + $creds = new FetchAuthTokenCache($creds, $cacheConfig, $cache); + } + return $creds; + } + + /** + * Returns a StdOutLogger instance + * + * @internal + * + * @return null|LoggerInterface + */ + public static function getDefaultLogger(): null|LoggerInterface + { + $loggingFlag = getenv(self::SDK_DEBUG_ENV_VAR); + + // Env var is not set + if (empty($loggingFlag)) { + return null; + } + + $loggingFlag = strtolower($loggingFlag); + + // Env Var is not true + if ($loggingFlag !== 'true') { + if ($loggingFlag !== 'false') { + trigger_error( + 'The ' . + self::SDK_DEBUG_ENV_VAR . + ' is set, but it is set to another value than false or true. Logging is disabled' + ); + } + + return null; + } + + return new StdOutLogger(); + } + + /** + * @return string + */ + private static function notFound() + { + $msg = 'Your default credentials were not found. To set up '; + $msg .= 'Application Default Credentials, see '; + $msg .= 'https://cloud.google.com/docs/authentication/external/set-up-adc'; + + return $msg; + } + + /** + * @param callable|null $httpHandler + * @param array|null $cacheConfig + * @param CacheItemPoolInterface|null $cache + * @return bool + */ + private static function onGce( + ?callable $httpHandler = null, + ?array $cacheConfig = null, + ?CacheItemPoolInterface $cache = null + ) { + $gceCacheConfig = []; + foreach (['lifetime', 'prefix'] as $key) { + if (isset($cacheConfig['gce_' . $key])) { + $gceCacheConfig[$key] = $cacheConfig['gce_' . $key]; + } + } + + return (new GCECache($gceCacheConfig, $cache))->onGce($httpHandler); + } +} diff --git a/Auth/src/Cache/FileSystemCacheItemPool.php b/Auth/src/Cache/FileSystemCacheItemPool.php new file mode 100644 index 000000000000..07162274ef5a --- /dev/null +++ b/Auth/src/Cache/FileSystemCacheItemPool.php @@ -0,0 +1,236 @@ + + */ + private array $buffer = []; + + /** + * Creates a FileSystemCacheItemPool cache that stores values in local storage + * + * @param string $path The string representation of the path where the cache will store the serialized objects. + */ + public function __construct(string $path) + { + $this->cachePath = $path; + + if (is_dir($this->cachePath)) { + return; + } + + // Suppress the error for when the directory already exists because of a + // race condition + if (!@mkdir($this->cachePath, 0777, true) && !is_dir($this->cachePath)) { + throw new ErrorException("Cache folder couldn't be created."); + } + } + + /** + * {@inheritdoc} + */ + public function getItem(string $key): CacheItemInterface + { + if (!$this->validKey($key)) { + throw new InvalidArgumentException( + 'The key \'' . $key . '\' is not valid. The key should follow the pattern |^[a-zA-Z0-9_\.! ]+$|' + ); + } + + $item = new TypedItem($key); + + $itemPath = $this->cacheFilePath($key); + + if (!file_exists($itemPath)) { + return $item; + } + + $serializedItem = file_get_contents($itemPath); + + if ($serializedItem === false) { + return $item; + } + + $item->set(unserialize($serializedItem)); + + return $item; + } + + /** + * {@inheritdoc} + * + * @return iterable An iterable object containing all the + * A traversable collection of Cache Items keyed by the cache keys of + * each item. A Cache item will be returned for each key, even if that + * key is not found. However, if no keys are specified then an empty + * traversable MUST be returned instead. + */ + public function getItems(array $keys = []): iterable + { + $result = []; + + foreach ($keys as $key) { + $result[$key] = $this->getItem($key); + } + + return $result; + } + + /** + * {@inheritdoc} + */ + public function save(CacheItemInterface $item): bool + { + if (!$this->validKey($item->getKey())) { + return false; + } + + $itemPath = $this->cacheFilePath($item->getKey()); + $serializedItem = serialize($item->get()); + + $result = file_put_contents($itemPath, $serializedItem, LOCK_EX); + + // 0 bytes write is considered a successful operation + if ($result === false) { + return false; + } + + return true; + } + + /** + * {@inheritdoc} + */ + public function hasItem(string $key): bool + { + return $this->getItem($key)->isHit(); + } + + /** + * {@inheritdoc} + */ + public function clear(): bool + { + $this->buffer = []; + + if (!is_dir($this->cachePath)) { + return false; + } + + $files = scandir($this->cachePath); + if (!$files) { + return false; + } + + foreach ($files as $fileName) { + if ($fileName === '.' || $fileName === '..') { + continue; + } + + if (!unlink($this->cachePath . '/' . $fileName)) { + return false; + } + } + + return true; + } + + /** + * {@inheritdoc} + */ + public function deleteItem(string $key): bool + { + if (!$this->validKey($key)) { + throw new InvalidArgumentException( + 'The key \'' . $key . '\' is not valid. The key should follow the pattern |^[a-zA-Z0-9_\.! ]+$|' + ); + } + + $itemPath = $this->cacheFilePath($key); + + if (!file_exists($itemPath)) { + return true; + } + + return unlink($itemPath); + } + + /** + * {@inheritdoc} + */ + public function deleteItems(array $keys): bool + { + $result = true; + + foreach ($keys as $key) { + if (!$this->deleteItem($key)) { + $result = false; + } + } + + return $result; + } + + /** + * {@inheritdoc} + */ + public function saveDeferred(CacheItemInterface $item): bool + { + array_push($this->buffer, $item); + + return true; + } + + /** + * {@inheritdoc} + */ + public function commit(): bool + { + $result = true; + + foreach ($this->buffer as $item) { + if (!$this->save($item)) { + $result = false; + } + } + + return $result; + } + + private function cacheFilePath(string $key): string + { + return $this->cachePath . '/' . $key; + } + + private function validKey(string $key): bool + { + return (bool) preg_match('|^[a-zA-Z0-9_\.]+$|', $key); + } +} diff --git a/Auth/src/Cache/InvalidArgumentException.php b/Auth/src/Cache/InvalidArgumentException.php new file mode 100644 index 000000000000..331e56110047 --- /dev/null +++ b/Auth/src/Cache/InvalidArgumentException.php @@ -0,0 +1,24 @@ +getItems([$key])); // @phpstan-ignore-line + } + + /** + * {@inheritdoc} + * + * @return iterable + * A traversable collection of Cache Items keyed by the cache keys of + * each item. A Cache item will be returned for each key, even if that + * key is not found. However, if no keys are specified then an empty + * traversable MUST be returned instead. + */ + public function getItems(array $keys = []): iterable + { + $items = []; + foreach ($keys as $key) { + $items[$key] = $this->hasItem($key) ? clone $this->items[$key] : new TypedItem($key); + } + + return $items; + } + + /** + * {@inheritdoc} + * + * @return bool + * True if item exists in the cache, false otherwise. + */ + public function hasItem($key): bool + { + $this->isValidKey($key); + + return isset($this->items[$key]) && $this->items[$key]->isHit(); + } + + /** + * {@inheritdoc} + * + * @return bool + * True if the pool was successfully cleared. False if there was an error. + */ + public function clear(): bool + { + $this->items = []; + $this->deferredItems = []; + + return true; + } + + /** + * {@inheritdoc} + * + * @return bool + * True if the item was successfully removed. False if there was an error. + */ + public function deleteItem($key): bool + { + return $this->deleteItems([$key]); + } + + /** + * {@inheritdoc} + * + * @return bool + * True if the items were successfully removed. False if there was an error. + */ + public function deleteItems(array $keys): bool + { + array_walk($keys, [$this, 'isValidKey']); + + foreach ($keys as $key) { + unset($this->items[$key]); + } + + return true; + } + + /** + * {@inheritdoc} + * + * @return bool + * True if the item was successfully persisted. False if there was an error. + */ + public function save(CacheItemInterface $item): bool + { + $this->items[$item->getKey()] = $item; + + return true; + } + + /** + * {@inheritdoc} + * + * @return bool + * False if the item could not be queued or if a commit was attempted and failed. True otherwise. + */ + public function saveDeferred(CacheItemInterface $item): bool + { + $this->deferredItems[$item->getKey()] = $item; + + return true; + } + + /** + * {@inheritdoc} + * + * @return bool + * True if all not-yet-saved items were successfully saved or there were none. False otherwise. + */ + public function commit(): bool + { + foreach ($this->deferredItems as $item) { + $this->save($item); + } + + $this->deferredItems = []; + + return true; + } + + /** + * Determines if the provided key is valid. + * + * @param string $key + * @return bool + * @throws InvalidArgumentException + */ + private function isValidKey($key) + { + $invalidCharacters = '{}()/\\\\@:'; + + if (!is_string($key) || preg_match("#[$invalidCharacters]#", $key)) { + throw new InvalidArgumentException('The provided key is not valid: ' . var_export($key, true)); + } + + return true; + } +} diff --git a/Auth/src/Cache/SysVCacheItemPool.php b/Auth/src/Cache/SysVCacheItemPool.php new file mode 100644 index 000000000000..9265a119d762 --- /dev/null +++ b/Auth/src/Cache/SysVCacheItemPool.php @@ -0,0 +1,352 @@ + + */ + private $options; + + /** + * @var bool + */ + private $hasLoadedItems = false; + + /** + * @var SysvSemaphore|false + */ + private SysvSemaphore|false $semId = false; + + /** + * Maintain the process which is currently holding the semaphore to prevent deadlock. + * + * @var int|null + */ + private ?int $lockOwnerPid = null; + + /** + * Create a SystemV shared memory based CacheItemPool. + * + * @param array $options { + * [optional] Configuration options. + * + * @type int $variableKey The variable key for getting the data from the shared memory. **Defaults to** 1. + * @type string $proj The project identifier for ftok. This needs to be a one character string. + * **Defaults to** 'A'. + * @type string $semProj The project identifier for ftok to provide to `sem_get`. This needs to be a one + * character string. + * **Defaults to** 'B'. + * @type int $memsize The memory size in bytes for shm_attach. **Defaults to** 10000. + * @type int $perm The permission for shm_attach. **Defaults to** 0600. + * } + */ + public function __construct($options = []) + { + if (!extension_loaded('sysvshm')) { + throw new \RuntimeException( + 'sysvshm extension is required to use this ItemPool' + ); + } + $this->options = $options + [ + 'variableKey' => self::VAR_KEY, + 'proj' => self::DEFAULT_PROJ, + 'semProj' => self::DEFAULT_SEM_PROJ, + 'memsize' => self::DEFAULT_MEMSIZE, + 'perm' => self::DEFAULT_PERM + ]; + $this->items = []; + $this->deferredItems = []; + $this->sysvKey = ftok(__FILE__, $this->options['proj']); + + // gracefully handle when `sysvsem` isn't loaded + // @TODO(v2): throw an exception when the extension isn't loaded + if (extension_loaded('sysvsem')) { + $semKey = ftok(__FILE__, $this->options['semProj']); + $this->semId = sem_get($semKey, 1, $this->options['perm'], true); + } + } + + /** + * @param mixed $key + * @return CacheItemInterface + */ + public function getItem($key): CacheItemInterface + { + $this->loadItems(); + return current($this->getItems([$key])); // @phpstan-ignore-line + } + + /** + * @param array $keys + * @return iterable + */ + public function getItems(array $keys = []): iterable + { + $this->loadItems(); + $items = []; + foreach ($keys as $key) { + $items[$key] = $this->hasItem($key) ? + clone $this->items[$key] : + new TypedItem($key); + } + return $items; + } + + /** + * {@inheritdoc} + */ + public function hasItem($key): bool + { + $this->loadItems(); + return isset($this->items[$key]) && $this->items[$key]->isHit(); + } + + /** + * {@inheritdoc} + */ + public function clear(): bool + { + if (!$this->acquireLock()) { + return false; + } + + $this->items = []; + $this->deferredItems = []; + $ret = $this->saveCurrentItems(); + + $this->resetShm(); + $this->releaseLock(); + return $ret; + } + + /** + * {@inheritdoc} + */ + public function deleteItem($key): bool + { + return $this->deleteItems([$key]); + } + + /** + * {@inheritdoc} + */ + public function deleteItems(array $keys): bool + { + if (!$this->acquireLock()) { + return false; + } + + if (!$this->hasLoadedItems) { + $this->loadItems(); + } + + foreach ($keys as $key) { + unset($this->items[$key]); + } + $ret = $this->saveCurrentItems(); + + $this->resetShm(); + $this->releaseLock(); + return $ret; + } + + /** + * {@inheritdoc} + */ + public function save(CacheItemInterface $item): bool + { + if (!$this->acquireLock()) { + return false; + } + + if (!$this->hasLoadedItems) { + $this->loadItems(); + } + + $this->items[$item->getKey()] = $item; + $ret = $this->saveCurrentItems(); + $this->releaseLock(); + return $ret; + } + + /** + * {@inheritdoc} + */ + public function saveDeferred(CacheItemInterface $item): bool + { + $this->deferredItems[$item->getKey()] = $item; + return true; + } + + /** + * {@inheritdoc} + */ + public function commit(): bool + { + if (!$this->acquireLock()) { + return false; + } + + foreach ($this->deferredItems as $item) { + if ($this->save($item) === false) { + $this->releaseLock(); + return false; + } + } + $this->deferredItems = []; + $this->releaseLock(); + return true; + } + + /** + * Save the current items. + * + * @return bool true when success, false upon failure + */ + private function saveCurrentItems() + { + if (!$this->acquireLock()) { + return false; + } + + if (false !== $shmid = $this->attachShm()) { + $success = shm_put_var( + $shmid, + $this->options['variableKey'], + $this->items + ); + shm_detach($shmid); + $this->releaseLock(); + return $success; + } + $this->releaseLock(); + return false; + } + + /** + * Load the items from the shared memory. + * + * @return bool true when success, false upon failure + */ + private function loadItems() + { + if (!$this->acquireLock()) { + return false; + } + + if (false !== $shmid = $this->attachShm()) { + $data = @shm_get_var($shmid, $this->options['variableKey']); + $this->items = $data ?: []; + shm_detach($shmid); + $this->hasLoadedItems = true; + $this->releaseLock(); + return true; + } + $this->releaseLock(); + return false; + } + + private function acquireLock(): bool + { + if ($this->semId === false) { + // if `sysvsem` isn't loaded, or if `sem_get` fails, return true + // this ensures BC with previous versions of the auth library. + // @TODO consider better handling when `sem_get` fails. + return true; + } + + $currentPid = getmypid(); + if ($this->lockOwnerPid === $currentPid) { + // We already have the lock + return true; + } + + if (sem_acquire($this->semId)) { + $this->lockOwnerPid = (int) $currentPid; + return true; + } + return false; + } + + private function releaseLock(): bool + { + if ($this->semId === false || $this->lockOwnerPid !== getmypid()) { + return true; + } + + $this->lockOwnerPid = null; + return sem_release($this->semId); + } + + private function resetShm(): void + { + // Remove the shared memory segment and semaphore when clearing the cache + $shmid = @shm_attach($this->sysvKey); + if ($shmid !== false) { + @shm_remove($shmid); + @shm_detach($shmid); + } + } + + private function attachShm(): SysvSharedMemory|false + { + return shm_attach( + $this->sysvKey, + $this->options['memsize'], + $this->options['perm'] + ); + } +} diff --git a/Auth/src/Cache/TypedItem.php b/Auth/src/Cache/TypedItem.php new file mode 100644 index 000000000000..cce6740a4b56 --- /dev/null +++ b/Auth/src/Cache/TypedItem.php @@ -0,0 +1,170 @@ +key = $key; + $this->expiration = null; + } + + /** + * {@inheritdoc} + */ + public function getKey(): string + { + return $this->key; + } + + /** + * {@inheritdoc} + */ + public function get(): mixed + { + return $this->isHit() ? $this->value : null; + } + + /** + * {@inheritdoc} + */ + public function isHit(): bool + { + if (!$this->isHit) { + return false; + } + + if ($this->expiration === null) { + return true; + } + + return $this->currentTime()->getTimestamp() < $this->expiration->getTimestamp(); + } + + /** + * {@inheritdoc} + */ + public function set(mixed $value): static + { + $this->isHit = true; + $this->value = $value; + + return $this; + } + + /** + * {@inheritdoc} + */ + public function expiresAt($expiration): static + { + if ($this->isValidExpiration($expiration)) { + $this->expiration = $expiration; + + return $this; + } + + $error = sprintf( + 'Argument 1 passed to %s::expiresAt() must implement interface DateTimeInterface, %s given', + get_class($this), + gettype($expiration) + ); + + throw new \TypeError($error); + } + + /** + * {@inheritdoc} + */ + public function expiresAfter($time): static + { + if (is_int($time)) { + $this->expiration = $this->currentTime()->add(new \DateInterval("PT{$time}S")); + } elseif ($time instanceof \DateInterval) { + $this->expiration = $this->currentTime()->add($time); + } elseif ($time === null) { + $this->expiration = $time; + } else { + $message = 'Argument 1 passed to %s::expiresAfter() must be an ' . + 'instance of DateInterval or of the type integer, %s given'; + $error = sprintf($message, get_class($this), gettype($time)); + + throw new \TypeError($error); + } + + return $this; + } + + /** + * Determines if an expiration is valid based on the rules defined by PSR6. + * + * @param mixed $expiration + * @return bool + */ + private function isValidExpiration($expiration) + { + if ($expiration === null) { + return true; + } + + // We test for two types here due to the fact the DateTimeInterface + // was not introduced until PHP 5.5. Checking for the DateTime type as + // well allows us to support 5.4. + if ($expiration instanceof \DateTimeInterface) { + return true; + } + + return false; + } + + /** + * @return \DateTime + */ + protected function currentTime() + { + return new \DateTime('now', new \DateTimeZone('UTC')); + } +} diff --git a/Auth/src/CacheTrait.php b/Auth/src/CacheTrait.php new file mode 100644 index 000000000000..a991c57713b3 --- /dev/null +++ b/Auth/src/CacheTrait.php @@ -0,0 +1,111 @@ + + */ + private $cacheConfig; + + /** + * @var ?CacheItemPoolInterface + */ + private $cache; + + /** + * Gets the cached value if it is present in the cache when that is + * available. + * + * @param mixed $k + * + * @return mixed + */ + private function getCachedValue($k) + { + if (is_null($this->cache)) { + return null; + } + + $key = $this->getFullCacheKey($k); + if (is_null($key)) { + return null; + } + + $cacheItem = $this->cache->getItem($key); + if ($cacheItem->isHit()) { + return $cacheItem->get(); + } + } + + /** + * Saves the value in the cache when that is available. + * + * @param mixed $k + * @param mixed $v + * @param int|null $lifetime + * @return mixed + */ + private function setCachedValue($k, $v, ?int $lifetime = null) + { + if (is_null($this->cache)) { + return null; + } + + $key = $this->getFullCacheKey($k); + if (is_null($key)) { + return null; + } + + $cacheItem = $this->cache->getItem($key); + $cacheItem->set($v); + $cacheItem->expiresAfter($lifetime ?? $this->cacheConfig['lifetime']); + return $this->cache->save($cacheItem); + } + + /** + * @param null|string $key + * @return null|string + */ + private function getFullCacheKey($key) + { + if (is_null($key)) { + return null; + } + + $key = ($this->cacheConfig['prefix'] ?? '') . $key; + + // ensure we do not have illegal characters + $key = preg_replace('|[^a-zA-Z0-9_\.!]|', '', $key); + + // Hash keys if they exceed $maxKeyLength (defaults to 64) + if ($this->maxKeyLength && strlen($key) > $this->maxKeyLength) { + $key = substr(hash('sha256', $key), 0, $this->maxKeyLength); + } + + return $key; + } +} diff --git a/Auth/src/CredentialSource/AwsNativeSource.php b/Auth/src/CredentialSource/AwsNativeSource.php new file mode 100644 index 000000000000..1c18846afe5e --- /dev/null +++ b/Auth/src/CredentialSource/AwsNativeSource.php @@ -0,0 +1,438 @@ +audience = $audience; + $this->regionalCredVerificationUrl = $regionalCredVerificationUrl; + $this->regionUrl = $regionUrl; + $this->securityCredentialsUrl = $securityCredentialsUrl; + $this->imdsv2SessionTokenUrl = $imdsv2SessionTokenUrl; + } + + public function fetchSubjectToken(?callable $httpHandler = null): string + { + if (is_null($httpHandler)) { + $httpHandler = HttpHandlerFactory::build(HttpClientCache::getHttpClient()); + } + + $headers = []; + if ($this->imdsv2SessionTokenUrl) { + $headers = [ + 'X-aws-ec2-metadata-token' => self::getImdsV2SessionToken($this->imdsv2SessionTokenUrl, $httpHandler) + ]; + } + + $signingVars = self::getSigningVarsFromEnv() + ?? self::getSigningVarsFromEcs($httpHandler); + + if (!$signingVars) { + if (!$this->securityCredentialsUrl) { + throw new \LogicException( + 'Unable to get credentials from ENV, and no security credentials URL provided' + ); + } + $signingVars = self::getSigningVarsFromUrl( + $httpHandler, + $this->securityCredentialsUrl, + self::getRoleName($httpHandler, $this->securityCredentialsUrl, $headers), + $headers + ); + } + + if (!$region = self::getRegionFromEnv()) { + if (!$this->regionUrl) { + throw new \LogicException('Unable to get region from ENV, and no region URL provided'); + } + $region = self::getRegionFromUrl($httpHandler, $this->regionUrl, $headers); + } + $url = str_replace('{region}', $region, $this->regionalCredVerificationUrl); + $host = parse_url($url)['host'] ?? ''; + + // From here we use the signing vars to create the signed request to receive a token + [$accessKeyId, $secretAccessKey, $securityToken] = $signingVars; + $headers = self::getSignedRequestHeaders($region, $host, $accessKeyId, $secretAccessKey, $securityToken); + + // Inject x-goog-cloud-target-resource into header + $headers['x-goog-cloud-target-resource'] = $this->audience; + + // Format headers as they're expected in the subject token + $formattedHeaders = array_map( + fn ($k, $v) => ['key' => $k, 'value' => $v], + array_keys($headers), + $headers, + ); + + $request = [ + 'headers' => $formattedHeaders, + 'method' => 'POST', + 'url' => $url, + ]; + + return urlencode(json_encode($request) ?: ''); + } + + /** + * @internal + */ + public static function getImdsV2SessionToken(string $imdsV2Url, callable $httpHandler): string + { + $headers = [ + 'X-aws-ec2-metadata-token-ttl-seconds' => '21600' + ]; + $request = new Request( + 'PUT', + $imdsV2Url, + $headers + ); + + $response = $httpHandler($request); + return (string) $response->getBody(); + } + + /** + * @see http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html + * + * @internal + * + * @return array + */ + public static function getSignedRequestHeaders( + string $region, + string $host, + string $accessKeyId, + string $secretAccessKey, + ?string $securityToken + ): array { + $service = 'sts'; + + # Create a date for headers and the credential string in ISO-8601 format + $amzdate = gmdate('Ymd\THis\Z'); + $datestamp = gmdate('Ymd'); # Date w/o time, used in credential scope + + # Create the canonical headers and signed headers. Header names + # must be trimmed and lowercase, and sorted in code point order from + # low to high. Note that there is a trailing \n. + $canonicalHeaders = sprintf("host:%s\nx-amz-date:%s\n", $host, $amzdate); + if ($securityToken) { + $canonicalHeaders .= sprintf("x-amz-security-token:%s\n", $securityToken); + } + + # Step 5: Create the list of signed headers. This lists the headers + # in the canonicalHeaders list, delimited with ";" and in alpha order. + # Note: The request can include any headers; $canonicalHeaders and + # $signedHeaders lists those that you want to be included in the + # hash of the request. "Host" and "x-amz-date" are always required. + $signedHeaders = 'host;x-amz-date'; + if ($securityToken) { + $signedHeaders .= ';x-amz-security-token'; + } + + # Step 6: Create payload hash (hash of the request body content). For GET + # requests, the payload is an empty string (""). + $payloadHash = hash('sha256', ''); + + # Step 7: Combine elements to create canonical request + $canonicalRequest = implode("\n", [ + 'POST', // method + '/', // canonical URL + self::CRED_VERIFICATION_QUERY, // query string + $canonicalHeaders, + $signedHeaders, + $payloadHash + ]); + + # ************* TASK 2: CREATE THE STRING TO SIGN************* + # Match the algorithm to the hashing algorithm you use, either SHA-1 or + # SHA-256 (recommended) + $algorithm = 'AWS4-HMAC-SHA256'; + $scope = implode('/', [$datestamp, $region, $service, 'aws4_request']); + $stringToSign = implode("\n", [$algorithm, $amzdate, $scope, hash('sha256', $canonicalRequest)]); + + # ************* TASK 3: CALCULATE THE SIGNATURE ************* + # Create the signing key using the function defined above. + // (done above) + $signingKey = self::getSignatureKey($secretAccessKey, $datestamp, $region, $service); + + # Sign the string_to_sign using the signing_key + $signature = bin2hex(self::hmacSign($signingKey, $stringToSign)); + + # ************* TASK 4: ADD SIGNING INFORMATION TO THE REQUEST ************* + # The signing information can be either in a query string value or in + # a header named Authorization. This code shows how to use a header. + # Create authorization header and add to request headers + $authorizationHeader = sprintf( + '%s Credential=%s/%s, SignedHeaders=%s, Signature=%s', + $algorithm, + $accessKeyId, + $scope, + $signedHeaders, + $signature + ); + + # The request can include any headers, but MUST include "host", "x-amz-date", + # and (for this scenario) "Authorization". "host" and "x-amz-date" must + # be included in the canonical_headers and signed_headers, as noted + # earlier. Order here is not significant. + $headers = [ + 'host' => $host, + 'x-amz-date' => $amzdate, + 'Authorization' => $authorizationHeader, + ]; + if ($securityToken) { + $headers['x-amz-security-token'] = $securityToken; + } + + return $headers; + } + + /** + * @internal + */ + public static function getRegionFromEnv(): ?string + { + $region = getenv('AWS_REGION'); + if (empty($region)) { + $region = getenv('AWS_DEFAULT_REGION'); + } + return $region ?: null; + } + + /** + * @internal + * + * @param callable $httpHandler + * @param string $regionUrl + * @param array $headers Request headers to send in with the request. + */ + public static function getRegionFromUrl(callable $httpHandler, string $regionUrl, array $headers): string + { + // get the region/zone from the region URL + $regionRequest = new Request('GET', $regionUrl, $headers); + $regionResponse = $httpHandler($regionRequest); + + // Remove last character. For example, if us-east-2b is returned, + // the region would be us-east-2. + return substr((string) $regionResponse->getBody(), 0, -1); + } + + /** + * @internal + * + * @param callable $httpHandler + * @param string $securityCredentialsUrl + * @param array $headers Request headers to send in with the request. + */ + public static function getRoleName(callable $httpHandler, string $securityCredentialsUrl, array $headers): string + { + // Get the AWS role name + $roleRequest = new Request('GET', $securityCredentialsUrl, $headers); + $roleResponse = $httpHandler($roleRequest); + $roleName = (string) $roleResponse->getBody(); + + return $roleName; + } + + /** + * @internal + * + * @param callable $httpHandler + * @param string $securityCredentialsUrl + * @param array $headers Request headers to send in with the request. + * @return array{string, string, ?string} + */ + public static function getSigningVarsFromUrl( + callable $httpHandler, + string $securityCredentialsUrl, + string $roleName, + array $headers + ): array { + // Get the AWS credentials + $credsRequest = new Request( + 'GET', + $securityCredentialsUrl . '/' . $roleName, + $headers + ); + $credsResponse = $httpHandler($credsRequest); + $awsCreds = json_decode((string) $credsResponse->getBody(), true); + return [ + $awsCreds['AccessKeyId'], // accessKeyId + $awsCreds['SecretAccessKey'], // secretAccessKey + $awsCreds['Token'], // token + ]; + } + + /** + * @internal + * + * @param callable $httpHandler + * @return array{string, string, ?string}|null + */ + public static function getSigningVarsFromEcs(callable $httpHandler): ?array + { + // Load the environment variables defined by AWS for the ECS/EKS container metadata. + $ecsContainerCredentialsRelativeUri = getenv('AWS_CONTAINER_CREDENTIALS_RELATIVE_URI'); + $ecsContainerCredentialsFullUri = getenv('AWS_CONTAINER_CREDENTIALS_FULL_URI'); + $ecsContainerAuthorizationToken = getenv('AWS_CONTAINER_AUTHORIZATION_TOKEN'); + $ecsContainerAuthorizationTokenFile = getenv('AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE'); + + $credentialsUrl = ''; + // The full URI takes precedence over the relative URI if both are defined. + if ($ecsContainerCredentialsFullUri) { + $credentialsUrl = $ecsContainerCredentialsFullUri; + } elseif ($ecsContainerCredentialsRelativeUri) { + // The relative URI is appended to the default ECS Task Metadata Endpoint. + $credentialsUrl = self::ECS_CONTAINER_METADATA_URL . $ecsContainerCredentialsRelativeUri; + } else { + // Not running in an ECS environment, or metadata is not enabled. + return null; + } + + $headers = []; + // The authorization token file takes precedence over the direct token variable. + if ($ecsContainerAuthorizationTokenFile) { + if (is_readable($ecsContainerAuthorizationTokenFile)) { + $headers['Authorization'] = trim((string) file_get_contents($ecsContainerAuthorizationTokenFile)); + } else { + throw new \RuntimeException( + sprintf('Token file %s is not readable', $ecsContainerAuthorizationTokenFile) + ); + } + } elseif ($ecsContainerAuthorizationToken) { + $headers['Authorization'] = $ecsContainerAuthorizationToken; + } + + // Fetch the temporary AWS credentials from the resolved metadata endpoint. + $credsRequest = new Request('GET', $credentialsUrl, $headers); + $credsResponse = $httpHandler($credsRequest); + $awsCreds = json_decode((string) $credsResponse->getBody(), true); + + // Ensure the response has the minimum required credential fields. + if (!is_array($awsCreds) || !isset($awsCreds['AccessKeyId']) || !isset($awsCreds['SecretAccessKey'])) { + throw new \UnexpectedValueException('Invalid or missing ECS credentials in response'); + } + + return [ + $awsCreds['AccessKeyId'], + $awsCreds['SecretAccessKey'], + $awsCreds['Token'] ?? null, + ]; + } + + /** + * @internal + * + * @return array{string, string, ?string} + */ + public static function getSigningVarsFromEnv(): ?array + { + $accessKeyId = getenv('AWS_ACCESS_KEY_ID'); + $secretAccessKey = getenv('AWS_SECRET_ACCESS_KEY'); + if ($accessKeyId && $secretAccessKey) { + return [ + $accessKeyId, + $secretAccessKey, + getenv('AWS_SESSION_TOKEN') ?: null, // session token (can be null) + ]; + } + + return null; + } + + /** + * Gets the unique key for caching + * For AwsNativeSource the values are: + * Imdsv2SessionTokenUrl.SecurityCredentialsUrl.RegionUrl.RegionalCredVerificationUrl + * + * @return string + */ + public function getCacheKey(): string + { + return ($this->imdsv2SessionTokenUrl ?? '') . + '.' . ($this->securityCredentialsUrl ?? '') . + '.' . $this->regionUrl . + '.' . $this->regionalCredVerificationUrl; + } + + /** + * Return HMAC hash in binary string + */ + private static function hmacSign(string $key, string $msg): string + { + return hash_hmac('sha256', self::utf8Encode($msg), $key, true); + } + + /** + * @TODO add a fallback when mbstring is not available + */ + private static function utf8Encode(string $string): string + { + return (string) mb_convert_encoding($string, 'UTF-8', 'ISO-8859-1'); + } + + private static function getSignatureKey( + string $key, + string $dateStamp, + string $regionName, + string $serviceName + ): string { + $kDate = self::hmacSign(self::utf8Encode('AWS4' . $key), $dateStamp); + $kRegion = self::hmacSign($kDate, $regionName); + $kService = self::hmacSign($kRegion, $serviceName); + $kSigning = self::hmacSign($kService, 'aws4_request'); + + return $kSigning; + } +} diff --git a/Auth/src/CredentialSource/ExecutableSource.php b/Auth/src/CredentialSource/ExecutableSource.php new file mode 100644 index 000000000000..e8f9c4ecaca2 --- /dev/null +++ b/Auth/src/CredentialSource/ExecutableSource.php @@ -0,0 +1,277 @@ + + * OIDC response sample: + * { + * "version": 1, + * "success": true, + * "token_type": "urn:ietf:params:oauth:token-type:id_token", + * "id_token": "HEADER.PAYLOAD.SIGNATURE", + * "expiration_time": 1620433341 + * } + * + * SAML2 response sample: + * { + * "version": 1, + * "success": true, + * "token_type": "urn:ietf:params:oauth:token-type:saml2", + * "saml_response": "...", + * "expiration_time": 1620433341 + * } + * + * Error response sample: + * { + * "version": 1, + * "success": false, + * "code": "401", + * "message": "Error message." + * } + * + * + * The "expiration_time" field in the JSON response is only required for successful + * responses when an output file was specified in the credential configuration + * + * The auth libraries will populate certain environment variables that will be accessible by the + * executable, such as: GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE, GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE, + * GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE, GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL, and + * GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE. + */ +class ExecutableSource implements ExternalAccountCredentialSourceInterface +{ + private const GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES = 'GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES'; + private const SAML_SUBJECT_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:saml2'; + private const OIDC_SUBJECT_TOKEN_TYPE1 = 'urn:ietf:params:oauth:token-type:id_token'; + private const OIDC_SUBJECT_TOKEN_TYPE2 = 'urn:ietf:params:oauth:token-type:jwt'; + + private string $command; + private ExecutableHandler $executableHandler; + private ?string $outputFile; + + /** + * @param string $command The string command to run to get the subject token. + * @param string|null $outputFile + */ + public function __construct( + string $command, + ?string $outputFile, + ?ExecutableHandler $executableHandler = null, + ) { + $this->command = $command; + $this->outputFile = $outputFile; + $this->executableHandler = $executableHandler ?: new ExecutableHandler(); + } + + /** + * Gets the unique key for caching + * The format for the cache key is: + * Command.OutputFile + * + * @return ?string + */ + public function getCacheKey(): ?string + { + return $this->command . '.' . $this->outputFile; + } + + /** + * @param callable|null $httpHandler unused. + * @return string + * @throws RuntimeException if the executable is not allowed to run. + * @throws ExecutableResponseError if the executable response is invalid. + */ + public function fetchSubjectToken(?callable $httpHandler = null): string + { + // Check if the executable is allowed to run. + if (getenv(self::GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES) !== '1') { + throw new RuntimeException( + 'Pluggable Auth executables need to be explicitly allowed to run by ' + . 'setting the GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES environment ' + . 'Variable to 1.' + ); + } + + if (!$executableResponse = $this->getCachedExecutableResponse()) { + // Run the executable. + $exitCode = ($this->executableHandler)($this->command); + $output = $this->executableHandler->getOutput(); + + // If the exit code is not 0, throw an exception with the output as the error details + if ($exitCode !== 0) { + throw new ExecutableResponseError( + 'The executable failed to run' + . ($output ? ' with the following error: ' . $output : '.'), + (string) $exitCode + ); + } + + $executableResponse = $this->parseExecutableResponse($output); + + // Validate expiration. + if (isset($executableResponse['expiration_time']) && time() >= $executableResponse['expiration_time']) { + throw new ExecutableResponseError('Executable response is expired.'); + } + } + + // Throw error when the request was unsuccessful + if ($executableResponse['success'] === false) { + throw new ExecutableResponseError($executableResponse['message'], (string) $executableResponse['code']); + } + + // Return subject token field based on the token type + return $executableResponse['token_type'] === self::SAML_SUBJECT_TOKEN_TYPE + ? $executableResponse['saml_response'] + : $executableResponse['id_token']; + } + + /** + * @return array|null + */ + private function getCachedExecutableResponse(): ?array + { + if ($this->outputFile && + file_exists($this->outputFile) && + !empty(trim($outputFileContents = (string) file_get_contents($this->outputFile))) + ) { + try { + $executableResponse = $this->parseExecutableResponse($outputFileContents); + } catch (ExecutableResponseError $e) { + throw new ExecutableResponseError( + 'Error in output file: ' . $e->getMessage(), + 'INVALID_OUTPUT_FILE' + ); + } + + if ($executableResponse['success'] === false) { + // If the cached token was unsuccessful, run the executable to get a new one. + return null; + } + + if (isset($executableResponse['expiration_time']) && time() >= $executableResponse['expiration_time']) { + // If the cached token is expired, run the executable to get a new one. + return null; + } + + return $executableResponse; + } + + return null; + } + + /** + * @return array + */ + private function parseExecutableResponse(string $response): array + { + $executableResponse = json_decode($response, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new ExecutableResponseError( + 'The executable returned an invalid response: ' . $response, + 'INVALID_RESPONSE' + ); + } + if (!array_key_exists('version', $executableResponse)) { + throw new ExecutableResponseError('Executable response must contain a "version" field.'); + } + if (!array_key_exists('success', $executableResponse)) { + throw new ExecutableResponseError('Executable response must contain a "success" field.'); + } + + // Validate required fields for a successful response. + if ($executableResponse['success']) { + // Validate token type field. + $tokenTypes = [ + self::SAML_SUBJECT_TOKEN_TYPE, + self::OIDC_SUBJECT_TOKEN_TYPE1, + self::OIDC_SUBJECT_TOKEN_TYPE2 + ]; + if (!isset($executableResponse['token_type'])) { + throw new ExecutableResponseError( + 'Executable response must contain a "token_type" field when successful' + ); + } + if (!in_array($executableResponse['token_type'], $tokenTypes)) { + throw new ExecutableResponseError(sprintf( + 'Executable response "token_type" field must be one of %s.', + implode(', ', $tokenTypes) + )); + } + + // Validate subject token for SAML and OIDC. + if ($executableResponse['token_type'] === self::SAML_SUBJECT_TOKEN_TYPE) { + if (empty($executableResponse['saml_response'])) { + throw new ExecutableResponseError(sprintf( + 'Executable response must contain a "saml_response" field when token_type=%s.', + self::SAML_SUBJECT_TOKEN_TYPE + )); + } + } elseif (empty($executableResponse['id_token'])) { + throw new ExecutableResponseError(sprintf( + 'Executable response must contain a "id_token" field when ' + . 'token_type=%s.', + $executableResponse['token_type'] + )); + } + + // Validate expiration exists when an output file is specified. + if ($this->outputFile) { + if (!isset($executableResponse['expiration_time'])) { + throw new ExecutableResponseError( + 'The executable response must contain a "expiration_time" field for successful responses ' . + 'when an output_file has been specified in the configuration.' + ); + } + } + } else { + // Both code and message must be provided for unsuccessful responses. + if (!array_key_exists('code', $executableResponse)) { + throw new ExecutableResponseError('Executable response must contain a "code" field when unsuccessful.'); + } + if (empty($executableResponse['message'])) { + throw new ExecutableResponseError( + 'Executable response must contain a "message" field when unsuccessful.' + ); + } + } + + return $executableResponse; + } +} diff --git a/Auth/src/CredentialSource/FileSource.php b/Auth/src/CredentialSource/FileSource.php new file mode 100644 index 000000000000..27c93dc075c3 --- /dev/null +++ b/Auth/src/CredentialSource/FileSource.php @@ -0,0 +1,87 @@ +file = $file; + + if ($format === 'json' && is_null($subjectTokenFieldName)) { + throw new InvalidArgumentException( + 'subject_token_field_name must be set when format is JSON' + ); + } + + $this->format = $format; + $this->subjectTokenFieldName = $subjectTokenFieldName; + } + + public function fetchSubjectToken(?callable $httpHandler = null): string + { + $contents = file_get_contents($this->file); + if ($this->format === 'json') { + if (!$json = json_decode((string) $contents, true)) { + throw new UnexpectedValueException( + 'Unable to decode JSON file' + ); + } + if (!isset($json[$this->subjectTokenFieldName])) { + throw new UnexpectedValueException( + 'subject_token_field_name not found in JSON file' + ); + } + $contents = $json[$this->subjectTokenFieldName]; + } + + return $contents; + } + + /** + * Gets the unique key for caching. + * The format for the cache key one of the following: + * Filename + * + * @return string + */ + public function getCacheKey(): ?string + { + return $this->file; + } +} diff --git a/Auth/src/CredentialSource/UrlSource.php b/Auth/src/CredentialSource/UrlSource.php new file mode 100644 index 000000000000..bb81d9e923ed --- /dev/null +++ b/Auth/src/CredentialSource/UrlSource.php @@ -0,0 +1,109 @@ + + */ + private ?array $headers; + + /** + * @param string $url The URL to fetch the subject token from. + * @param string|null $format The format of the token in the response. Can be null or "json". + * @param string|null $subjectTokenFieldName The name of the field containing the token in the response. + * This is required when format is "json". + * @param array|null $headers Request headers to send in with the request to the URL. + */ + public function __construct( + string $url, + ?string $format = null, + ?string $subjectTokenFieldName = null, + ?array $headers = null + ) { + $this->url = $url; + + if ($format === 'json' && is_null($subjectTokenFieldName)) { + throw new InvalidArgumentException( + 'subject_token_field_name must be set when format is JSON' + ); + } + + $this->format = $format; + $this->subjectTokenFieldName = $subjectTokenFieldName; + $this->headers = $headers; + } + + public function fetchSubjectToken(?callable $httpHandler = null): string + { + if (is_null($httpHandler)) { + $httpHandler = HttpHandlerFactory::build(HttpClientCache::getHttpClient()); + } + + $request = new Request( + 'GET', + $this->url, + $this->headers ?: [] + ); + + $response = $httpHandler($request); + $body = (string) $response->getBody(); + if ($this->format === 'json') { + if (!$json = json_decode((string) $body, true)) { + throw new UnexpectedValueException( + 'Unable to decode JSON response' + ); + } + if (!isset($json[$this->subjectTokenFieldName])) { + throw new UnexpectedValueException( + 'subject_token_field_name not found in JSON file' + ); + } + $body = $json[$this->subjectTokenFieldName]; + } + + return $body; + } + + /** + * Get the cache key for the credentials. + * The format for the cache key is: + * URL + * + * @return ?string + */ + public function getCacheKey(): ?string + { + return $this->url; + } +} diff --git a/Auth/src/Credentials/AppIdentityCredentials.php b/Auth/src/Credentials/AppIdentityCredentials.php new file mode 100644 index 000000000000..b7cd7c45e704 --- /dev/null +++ b/Auth/src/Credentials/AppIdentityCredentials.php @@ -0,0 +1,237 @@ +push($middleware); + * + * $client = new Client([ + * 'handler' => $stack, + * 'base_uri' => 'https://www.googleapis.com/books/v1', + * 'auth' => 'google_auth' + * ]); + * + * $res = $client->get('volumes?q=Henry+David+Thoreau&country=US'); + * ``` + * @deprecated + */ +class AppIdentityCredentials extends CredentialsLoader implements + SignBlobInterface, + ProjectIdProviderInterface +{ + /** + * Result of fetchAuthToken. + * + * @var array + */ + protected $lastReceivedToken; + + /** + * Array of OAuth2 scopes to be requested. + * + * @var string[] + */ + private $scope; + + /** + * @var string + */ + private $clientName; + + /** + * @param string|string[] $scope One or more scopes. + */ + public function __construct($scope = []) + { + $this->scope = is_array($scope) ? $scope : explode(' ', (string) $scope); + } + + /** + * Determines if this an App Engine instance, by accessing the + * SERVER_SOFTWARE environment variable (prod) or the APPENGINE_RUNTIME + * environment variable (dev). + * + * @return bool true if this an App Engine Instance, false otherwise + */ + public static function onAppEngine() + { + $appEngineProduction = isset($_SERVER['SERVER_SOFTWARE']) && + 0 === strpos($_SERVER['SERVER_SOFTWARE'], 'Google App Engine'); + if ($appEngineProduction) { + return true; + } + $appEngineDevAppServer = isset($_SERVER['APPENGINE_RUNTIME']) && + $_SERVER['APPENGINE_RUNTIME'] == 'php'; + if ($appEngineDevAppServer) { + return true; + } + return false; + } + + /** + * Implements FetchAuthTokenInterface#fetchAuthToken. + * + * Fetches the auth tokens using the AppIdentityService if available. + * As the AppIdentityService uses protobufs to fetch the access token, + * the GuzzleHttp\ClientInterface instance passed in will not be used. + * + * @param callable|null $httpHandler callback which delivers psr7 request + * @return array { + * A set of auth related metadata, containing the following + * + * @type string $access_token + * @type string $expiration_time + * } + */ + public function fetchAuthToken(?callable $httpHandler = null) + { + try { + $this->checkAppEngineContext(); + } catch (\Exception $e) { + return []; + } + + /** @phpstan-ignore-next-line */ + $token = AppIdentityService::getAccessToken($this->scope); + $this->lastReceivedToken = $token; + + return $token; + } + + /** + * Sign a string using AppIdentityService. + * + * @param string $stringToSign The string to sign. + * @param bool $forceOpenSsl [optional] Does not apply to this credentials + * type. + * @return string The signature, base64-encoded. + * @throws \Exception If AppEngine SDK or mock is not available. + */ + public function signBlob($stringToSign, $forceOpenSsl = false) + { + $this->checkAppEngineContext(); + + /** @phpstan-ignore-next-line */ + return base64_encode(AppIdentityService::signForApp($stringToSign)['signature']); + } + + /** + * Get the project ID from AppIdentityService. + * + * Returns null if AppIdentityService is unavailable. + * + * @param callable|null $httpHandler Not used by this type. + * @return string|null + */ + public function getProjectId(?callable $httpHandler = null) + { + try { + $this->checkAppEngineContext(); + } catch (\Exception $e) { + return null; + } + + /** @phpstan-ignore-next-line */ + return AppIdentityService::getApplicationId(); + } + + /** + * Get the client name from AppIdentityService. + * + * Subsequent calls to this method will return a cached value. + * + * @param callable|null $httpHandler Not used in this implementation. + * @return string + * @throws \Exception If AppEngine SDK or mock is not available. + */ + public function getClientName(?callable $httpHandler = null) + { + $this->checkAppEngineContext(); + + if (!$this->clientName) { + /** @phpstan-ignore-next-line */ + $this->clientName = AppIdentityService::getServiceAccountName(); + } + + return $this->clientName; + } + + /** + * @return array{access_token:string,expires_at:int}|null + */ + public function getLastReceivedToken() + { + if ($this->lastReceivedToken) { + return [ + 'access_token' => $this->lastReceivedToken['access_token'], + 'expires_at' => $this->lastReceivedToken['expiration_time'], + ]; + } + + return null; + } + + /** + * Caching is handled by the underlying AppIdentityService, return empty string + * to prevent caching. + * + * @return string + */ + public function getCacheKey() + { + return ''; + } + + /** + * @return void + */ + private function checkAppEngineContext() + { + if (!self::onAppEngine() || !class_exists('google\appengine\api\app_identity\AppIdentityService')) { + throw new \Exception( + 'This class must be run in App Engine, or you must include the AppIdentityService ' + . 'mock class defined in tests/mocks/AppIdentityService.php' + ); + } + } +} diff --git a/Auth/src/Credentials/ExternalAccountAuthorizedUserCredentials.php b/Auth/src/Credentials/ExternalAccountAuthorizedUserCredentials.php new file mode 100644 index 000000000000..a3f3346b7c2d --- /dev/null +++ b/Auth/src/Credentials/ExternalAccountAuthorizedUserCredentials.php @@ -0,0 +1,248 @@ + $jsonKey JSON credential file path or JSON credentials + * as an associative array + */ + public function __construct( + string|array|null $scope, + array $jsonKey, + ) { + if (!array_key_exists('client_id', $jsonKey)) { + throw new InvalidArgumentException( + 'json key is missing the client_id field' + ); + } + if (!array_key_exists('client_secret', $jsonKey)) { + throw new InvalidArgumentException( + 'json key is missing the client_secret field' + ); + } + if (!array_key_exists('refresh_token', $jsonKey)) { + throw new InvalidArgumentException( + 'json key is missing the refresh_token field' + ); + } + if (!array_key_exists('token_url', $jsonKey)) { + throw new InvalidArgumentException( + 'json key is missing the token_url field' + ); + } + + $this->clientId = $jsonKey['client_id']; + $this->clientSecret = $jsonKey['client_secret']; + $this->universeDomain = $jsonKey['universe_domain'] ?? GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN; + $this->auth = new OAuth2([ + 'refresh_token' => $jsonKey['refresh_token'], + 'tokenCredentialUri' => $jsonKey['token_url'], + 'scope' => $scope, + ]); + if (array_key_exists('quota_project_id', $jsonKey)) { + $this->quotaProject = (string) $jsonKey['quota_project_id']; + } + } + + /** + * @param callable|null $httpHandler + * @param array $headers + * + * @return array { + * A set of auth related metadata, containing the following + * + * @type string $access_token + * @type int $expires_in + * @type string $token_type + * } + */ + public function fetchAuthToken(?callable $httpHandler = null, array $headers = []) + { + $headers['Authorization'] = sprintf( + 'Basic %s', + base64_encode($this->clientId . ':' . $this->clientSecret) + ); + return $this->auth->fetchAuthToken( + $httpHandler, + $this->applyTokenEndpointMetrics($headers, 'at') + ); + } + + /** + * Updates metadata with the authorization token. + * + * @param array $metadata metadata hashmap + * @param string $authUri optional auth uri + * @param callable|null $httpHandler callback which delivers psr7 request + * @return array updated metadata hashmap + */ + public function updateMetadata( + $metadata, + $authUri = null, + ?callable $httpHandler = null + ) { + $metadata = $this->traitUpdateMetadata($metadata, $authUri, $httpHandler); + + if ($this->enableRegionalAccessBoundary) { + $metadata = $this->updateRegionalAccessBoundaryMetadata( + $metadata, + $this->buildRegionalAccessBoundaryLookupUrl(), + $this->getUniverseDomain(), + $httpHandler, + ); + } + + return $metadata; + } + + /** + * Return the Cache Key for the credentials. + * The format for the Cache key is + * Hash(ClientId.Scope.RefreshToken) + * + * @return string + */ + public function getCacheKey() + { + return hash('sha256', implode('.', [ + $this->clientId, + $this->auth->getScope(), + $this->auth->getRefreshToken() + ])); + } + + /** + * @return array + */ + public function getLastReceivedToken() + { + return $this->auth->getLastReceivedToken(); + } + + /** + * Get the quota project used for this API request + * + * @return string|null + */ + public function getQuotaProject(): string|null + { + return $this->quotaProject; + } + + /** + * Get the universe domain used for this API request + * + * @return string + */ + public function getUniverseDomain(): string + { + return $this->universeDomain; + } + + /** + * Get the granted scopes (if they exist) for the last fetched token. + * + * @return string|null + */ + public function getGrantedScope() + { + return $this->auth->getGrantedScope(); + } + + protected function getCredType(): string + { + return self::CRED_TYPE; + } + + /** + * Builds and returns the URL for the RAB lookup API. + */ + private function buildRegionalAccessBoundaryLookupUrl(): string + { + // Try to parse as a workload identity pool. + // Audience format: //iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID + $regex = '/projects\/([^\/]+)\/locations\/global\/workloadIdentityPools\/([^\/]+)/'; + if (preg_match($regex, $this->auth->getAudience(), $matches)) { + [$_, $projectNumber, $poolId] = $matches; + + return $this->traitBuildRegionalAccessBoundaryLookupUrl( + poolId: $poolId, + projectNumber: $projectNumber, + ); + } + + // If that fails, try to parse as a workforce pool. + // Audience format: //iam.googleapis.com/locations/global/workforcePools/POOL_ID/providers/PROVIDER_ID + if (preg_match('/locations\/[^\/]+\/workforcePools\/([^\/]+)/', $this->auth->getAudience(), $matches)) { + return $this->traitBuildRegionalAccessBoundaryLookupUrl( + poolId: $matches[1], + ); + } + + throw new LogicException('Invalid audience format'); + } +} diff --git a/Auth/src/Credentials/ExternalAccountCredentials.php b/Auth/src/Credentials/ExternalAccountCredentials.php new file mode 100644 index 000000000000..71200207364b --- /dev/null +++ b/Auth/src/Credentials/ExternalAccountCredentials.php @@ -0,0 +1,474 @@ + */ + private ?array $lastImpersonatedAccessToken; + private string $universeDomain; + + /** + * @param string|string[] $scope The scope of the access request, expressed either as an array + * or as a space-delimited string. + * @param array $jsonKey JSON credentials as an associative array. + * @param bool $enableRegionalAccessBoundary Lookup and include the regional access boundary header. + */ + public function __construct( + $scope, + array $jsonKey, + bool $enableRegionalAccessBoundary = false + ) { + if (!array_key_exists('type', $jsonKey)) { + throw new InvalidArgumentException('json key is missing the type field'); + } + if ($jsonKey['type'] !== self::EXTERNAL_ACCOUNT_TYPE) { + throw new InvalidArgumentException(sprintf( + 'expected "%s" type but received "%s"', + self::EXTERNAL_ACCOUNT_TYPE, + $jsonKey['type'] + )); + } + + if (!array_key_exists('token_url', $jsonKey)) { + throw new InvalidArgumentException( + 'json key is missing the token_url field' + ); + } + + if (!array_key_exists('audience', $jsonKey)) { + throw new InvalidArgumentException( + 'json key is missing the audience field' + ); + } + + if (!array_key_exists('subject_token_type', $jsonKey)) { + throw new InvalidArgumentException( + 'json key is missing the subject_token_type field' + ); + } + + if (!array_key_exists('credential_source', $jsonKey)) { + throw new InvalidArgumentException( + 'json key is missing the credential_source field' + ); + } + + $this->serviceAccountImpersonationUrl = $jsonKey['service_account_impersonation_url'] ?? null; + + $this->quotaProject = $jsonKey['quota_project_id'] ?? null; + $this->workforcePoolUserProject = $jsonKey['workforce_pool_user_project'] ?? null; + $this->universeDomain = $jsonKey['universe_domain'] ?? GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN; + $this->enableRegionalAccessBoundary = $enableRegionalAccessBoundary; + + $this->auth = new OAuth2([ + 'tokenCredentialUri' => $jsonKey['token_url'], + 'audience' => $jsonKey['audience'], + 'scope' => $scope, + 'subjectTokenType' => $jsonKey['subject_token_type'], + 'subjectTokenFetcher' => self::buildCredentialSource($jsonKey), + 'additionalOptions' => $this->workforcePoolUserProject + ? ['userProject' => $this->workforcePoolUserProject] + : [], + ]); + + if (!$this->isWorkforcePool() && $this->workforcePoolUserProject) { + throw new InvalidArgumentException( + 'workforce_pool_user_project should not be set for non-workforce pool credentials.' + ); + } + } + + /** + * @param array $jsonKey + */ + private static function buildCredentialSource(array $jsonKey): ExternalAccountCredentialSourceInterface + { + $credentialSource = $jsonKey['credential_source']; + if (isset($credentialSource['file'])) { + return new FileSource( + $credentialSource['file'], + $credentialSource['format']['type'] ?? null, + $credentialSource['format']['subject_token_field_name'] ?? null + ); + } + + if (isset($credentialSource['environment_id']) + && 1 === preg_match('/^aws(\d+)$/', $credentialSource['environment_id'], $matches) + ) { + if ($matches[1] !== '1') { + throw new InvalidArgumentException( + "aws version \"$matches[1]\" is not supported in the current build." + ); + } + if (!array_key_exists('regional_cred_verification_url', $credentialSource)) { + throw new InvalidArgumentException( + 'The regional_cred_verification_url field is required for aws1 credential source.' + ); + } + + return new AwsNativeSource( + $jsonKey['audience'], + $credentialSource['regional_cred_verification_url'], // $regionalCredVerificationUrl + $credentialSource['region_url'] ?? null, // $regionUrl + $credentialSource['url'] ?? null, // $securityCredentialsUrl + $credentialSource['imdsv2_session_token_url'] ?? null, // $imdsV2TokenUrl + ); + } + + if (isset($credentialSource['url'])) { + return new UrlSource( + $credentialSource['url'], + $credentialSource['format']['type'] ?? null, + $credentialSource['format']['subject_token_field_name'] ?? null, + $credentialSource['headers'] ?? null, + ); + } + + if (isset($credentialSource['executable'])) { + if (!array_key_exists('command', $credentialSource['executable'])) { + throw new InvalidArgumentException( + 'executable source requires a command to be set in the JSON file.' + ); + } + + // Build command environment variables + $env = [ + 'GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE' => $jsonKey['audience'], + 'GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE' => $jsonKey['subject_token_type'], + // Always set to 0 because interactive mode is not supported. + 'GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE' => '0', + ]; + + if ($outputFile = $credentialSource['executable']['output_file'] ?? null) { + $env['GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE'] = $outputFile; + } + + if ($serviceAccountImpersonationUrl = $jsonKey['service_account_impersonation_url'] ?? null) { + if ($email = self::getServiceAccountImpersonationEmail($serviceAccountImpersonationUrl)) { + $env['GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL'] = $email; + } + } + + $timeoutMs = $credentialSource['executable']['timeout_millis'] ?? null; + + return new ExecutableSource( + $credentialSource['executable']['command'], + $outputFile, + $timeoutMs ? new ExecutableHandler($env, $timeoutMs) : new ExecutableHandler($env) + ); + } + + throw new InvalidArgumentException('Unable to determine credential source from json key.'); + } + + private static function getServiceAccountImpersonationEmail(string $serviceAccountImpersonationUrl): string|null + { + // Parse email from URL. The formal looks as follows: + // https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/name@project-id.iam.gserviceaccount.com:generateAccessToken + $regex = '/serviceAccounts\/(?[^:]+):generateAccessToken$/'; + if (preg_match($regex, $serviceAccountImpersonationUrl, $matches)) { + return $matches['email']; + } + + return null; + } + + /** + * @param string $stsToken + * @param callable|null $httpHandler + * + * @return array { + * A set of auth related metadata, containing the following + * + * @type string $access_token + * @type int $expires_at + * } + */ + private function getImpersonatedAccessToken(string $stsToken, ?callable $httpHandler = null): array + { + if (!isset($this->serviceAccountImpersonationUrl)) { + throw new InvalidArgumentException( + 'service_account_impersonation_url must be set in JSON credentials.' + ); + } + $request = new Request( + 'POST', + $this->serviceAccountImpersonationUrl, + [ + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer ' . $stsToken, + ], + (string) json_encode([ + 'lifetime' => sprintf('%ss', OAuth2::DEFAULT_EXPIRY_SECONDS), + 'scope' => explode(' ', $this->auth->getScope()), + ]), + ); + if (is_null($httpHandler)) { + $httpHandler = HttpHandlerFactory::build(HttpClientCache::getHttpClient()); + } + $response = $httpHandler($request); + $body = json_decode((string) $response->getBody(), true); + return [ + 'access_token' => $body['accessToken'], + 'expires_at' => strtotime($body['expireTime']), + ]; + } + + /** + * @param callable|null $httpHandler + * @param array $headers [optional] Metrics headers to be inserted + * into the token endpoint request present. + * + * @return array { + * A set of auth related metadata, containing the following + * + * @type string $access_token + * @type int $expires_at (impersonated service accounts only) + * @type int $expires_in (identity pool only) + * @type string $issued_token_type (identity pool only) + * @type string $token_type (identity pool only) + * } + */ + public function fetchAuthToken(?callable $httpHandler = null, array $headers = []) + { + $stsToken = $this->auth->fetchAuthToken($httpHandler, $headers); + + if (isset($this->serviceAccountImpersonationUrl)) { + return $this->lastImpersonatedAccessToken = $this->getImpersonatedAccessToken( + $stsToken['access_token'], + $httpHandler + ); + } + + return $stsToken; + } + + /** + * Updates metadata with the authorization token. + * + * @param array $metadata metadata hashmap + * @param string $authUri optional auth uri + * @param callable|null $httpHandler callback which delivers psr7 request + * @return array updated metadata hashmap + */ + public function updateMetadata( + $metadata, + $authUri = null, + ?callable $httpHandler = null + ) { + $metadata = $this->traitUpdateMetadata($metadata, $authUri, $httpHandler); + + if ($this->enableRegionalAccessBoundary) { + $clientName = $this->serviceAccountImpersonationUrl + ? self::getServiceAccountImpersonationEmail($this->serviceAccountImpersonationUrl) + : null; + + $metadata = $this->updateRegionalAccessBoundaryMetadata( + $metadata, + $this->buildRegionalAccessBoundaryLookupUrl($clientName), + $this->getUniverseDomain(), + $httpHandler, + ); + } + + return $metadata; + } + + /** + * Get the cache token key for the credentials. + * The cache token key format depends on the type of source + * The format for the cache key one of the following: + * FetcherCacheKey.Scope.[ServiceAccount].[TokenType].[WorkforcePoolUserProject] + * FetcherCacheKey.Audience.[ServiceAccount].[TokenType].[WorkforcePoolUserProject] + * + * @return ?string; + */ + public function getCacheKey(): ?string + { + $scopeOrAudience = $this->auth->getAudience(); + if (!$scopeOrAudience) { + $scopeOrAudience = $this->auth->getScope(); + } + + return $this->auth->getSubjectTokenFetcher()->getCacheKey() . + '.' . $scopeOrAudience . + '.' . ($this->serviceAccountImpersonationUrl ?? '') . + '.' . ($this->auth->getSubjectTokenType() ?? '') . + '.' . ($this->workforcePoolUserProject ?? ''); + } + + public function getLastReceivedToken() + { + return $this->lastImpersonatedAccessToken ?? $this->auth->getLastReceivedToken(); + } + + /** + * Get the quota project used for this API request + * + * @return string|null + */ + public function getQuotaProject() + { + return $this->quotaProject; + } + + /** + * Get the universe domain used for this API request + * + * @return string + */ + public function getUniverseDomain(): string + { + return $this->universeDomain; + } + + /** + * Get the project ID. + * + * @param callable|null $httpHandler Callback which delivers psr7 request + * @param string|null $accessToken The access token to use to sign the blob. If + * provided, saves a call to the metadata server for a new access + * token. **Defaults to** `null`. + * @return string|null + */ + public function getProjectId(?callable $httpHandler = null, ?string $accessToken = null) + { + if (isset($this->projectId)) { + return $this->projectId; + } + + $projectNumber = $this->getProjectNumber() ?: $this->workforcePoolUserProject; + if (!$projectNumber) { + return null; + } + + if (is_null($httpHandler)) { + $httpHandler = HttpHandlerFactory::build(HttpClientCache::getHttpClient()); + } + + $url = str_replace( + 'UNIVERSE_DOMAIN', + $this->getUniverseDomain(), + sprintf(self::CLOUD_RESOURCE_MANAGER_URL, $projectNumber) + ); + + if (is_null($accessToken)) { + $accessToken = $this->fetchAuthToken($httpHandler)['access_token']; + } + + $request = new Request('GET', $url, ['authorization' => 'Bearer ' . $accessToken]); + $response = $httpHandler($request); + + $body = json_decode((string) $response->getBody(), true); + return $this->projectId = $body['projectId']; + } + + private function getProjectNumber(): ?string + { + $parts = explode('/', $this->auth->getAudience()); + $i = array_search('projects', $parts); + return $parts[$i + 1] ?? null; + } + + private function isWorkforcePool(): bool + { + $regex = '#//iam\.googleapis\.com/locations/[^/]+/workforcePools/#'; + return preg_match($regex, $this->auth->getAudience()) === 1; + } + + /** + * Builds and returns the URL for the regional access boundary lookup API. + */ + private function buildRegionalAccessBoundaryLookupUrl(string|null $clientName): string + { + if (null !== $clientName) { + return $this->traitBuildRegionalAccessBoundaryLookupUrl(serviceAccountEmail: $clientName); + } + + // Try to parse as a workload identity pool. + // Audience format: //iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID + $regex = '/projects\/([^\/]+)\/locations\/global\/workloadIdentityPools\/([^\/]+)/'; + if (preg_match($regex, $this->auth->getAudience(), $matches)) { + [$_, $projectNumber, $poolId] = $matches; + + return $this->traitBuildRegionalAccessBoundaryLookupUrl( + poolId: $poolId, + projectNumber: $projectNumber, + ); + } + + // If that fails, try to parse as a workforce pool. + // Audience format: //iam.googleapis.com/locations/global/workforcePools/POOL_ID/providers/PROVIDER_ID + if (preg_match('/locations\/[^\/]+\/workforcePools\/([^\/]+)/', $this->auth->getAudience(), $matches)) { + return $this->traitBuildRegionalAccessBoundaryLookupUrl( + poolId: $matches[1], + ); + } + + throw new LogicException('Invalid audience format'); + } +} diff --git a/Auth/src/Credentials/GCECredentials.php b/Auth/src/Credentials/GCECredentials.php new file mode 100644 index 000000000000..b08d0c8cf035 --- /dev/null +++ b/Auth/src/Credentials/GCECredentials.php @@ -0,0 +1,730 @@ +push($middleware); + * + * $client = new Client([ + * 'handler' => $stack, + * 'base_uri' => 'https://www.googleapis.com/taskqueue/v1beta2/projects/', + * 'auth' => 'google_auth' + * ]); + * + * $res = $client->get('myproject/taskqueues/myqueue'); + * ``` + */ +class GCECredentials extends CredentialsLoader implements + SignBlobInterface, + ProjectIdProviderInterface, + GetQuotaProjectInterface +{ + use IamSignerTrait; + use RegionalAccessBoundaryTrait; + + // phpcs:disable + const cacheKey = 'GOOGLE_AUTH_PHP_GCE'; + // phpcs:enable + + /** + * The metadata IP address on appengine instances. + * + * The IP is used instead of the domain 'metadata' to avoid slow responses + * when not on Compute Engine. + */ + const METADATA_IP = '169.254.169.254'; + + /** + * The metadata path of the default token. + */ + const TOKEN_URI_PATH = 'v1/instance/service-accounts/default/token'; + + /** + * The metadata path of the default id token. + */ + const ID_TOKEN_URI_PATH = 'v1/instance/service-accounts/default/identity'; + + /** + * The metadata path of the client ID. + */ + const CLIENT_ID_URI_PATH = 'v1/instance/service-accounts/default/email'; + + /** + * The metadata path of the project ID. + */ + const PROJECT_ID_URI_PATH = 'v1/project/project-id'; + + /** + * The metadata path of the project ID. + */ + const UNIVERSE_DOMAIN_URI_PATH = 'v1/universe/universe-domain'; + + /** + * The header whose presence indicates GCE presence. + */ + const FLAVOR_HEADER = 'Metadata-Flavor'; + + /** + * Flag used to determine whether to perform the GCE residency check. Used for testing. + */ + private static bool $checkResidency = true; + + /** + * The Linux file which contains the product name. + */ + private const GKE_PRODUCT_NAME_FILE = '/sys/class/dmi/id/product_name'; + + /** + * The Windows Registry key path to the product name + */ + private const WINDOWS_REGISTRY_KEY_PATH = 'HKEY_LOCAL_MACHINE\\SYSTEM\\HardwareConfig\\Current\\'; + + /** + * The Windows registry key name for the product name + */ + private const WINDOWS_REGISTRY_KEY_NAME = 'SystemProductName'; + + /** + * The Name of the product expected from the windows registry + */ + private const PRODUCT_NAME = 'Google'; + + private const CRED_TYPE = 'mds'; + + /** + * Note: the explicit `timeout` and `tries` below 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" since the expected 4-nines time is about 0.5 seconds. + * This allows us to limit the total ping maximum timeout to 1.5 seconds + * for developer desktop scenarios. + */ + const MAX_COMPUTE_PING_TRIES = 3; + const COMPUTE_PING_CONNECTION_TIMEOUT_S = 0.5; + + /** + * Flag used to ensure that the onGCE test is only done once;. + * + * @var bool + */ + private $hasCheckedOnGce = false; + + /** + * Flag that stores the value of the onGCE check. + * + * @var bool + */ + private $isOnGce = false; + + /** + * Result of fetchAuthToken. + * + * @var array + */ + protected $lastReceivedToken; + + /** + * @var string|null + */ + private $clientName; + + /** + * @var string|null + */ + private $projectId; + + /** + * @var string + */ + private $tokenUri; + + /** + * @var string + */ + private $targetAudience; + + /** + * @var string|null + */ + private $quotaProject; + + /** + * @var string|null + */ + private $serviceAccountIdentity; + + /** + * @var string + */ + private ?string $universeDomain; + + /** + * @param Iam|null $iam [optional] An IAM instance. + * @param string|string[] $scope [optional] the scope of the access request, + * expressed either as an array or as a space-delimited string. + * @param string $targetAudience [optional] The audience for the ID token. + * @param string $quotaProject [optional] Specifies a project to bill for access + * charges associated with the request. + * @param string $serviceAccountIdentity [optional] Specify a service + * account identity name to use instead of "default". + * @param string|null $universeDomain [optional] Specify a universe domain to use + * instead of fetching one from the metadata server. + * @param bool $enableRegionalAccessBoundary Lookup and include the regional access boundary header. + */ + public function __construct( + ?Iam $iam = null, + $scope = null, + $targetAudience = null, + $quotaProject = null, + $serviceAccountIdentity = null, + ?string $universeDomain = null, + bool $enableRegionalAccessBoundary = false + ) { + $this->iam = $iam; + + if ($scope && $targetAudience) { + throw new InvalidArgumentException( + 'Scope and targetAudience cannot both be supplied' + ); + } + + $tokenUri = self::getTokenUri($serviceAccountIdentity); + if ($scope) { + if (is_string($scope)) { + $scope = explode(' ', $scope); + } + + $scope = implode(',', $scope); + + $tokenUri = $tokenUri . '?scopes=' . $scope; + } elseif ($targetAudience) { + $tokenUri = self::getIdTokenUri($serviceAccountIdentity); + $tokenUri = $tokenUri . '?audience=' . $targetAudience; + $this->targetAudience = $targetAudience; + } + + $this->tokenUri = $tokenUri; + $this->quotaProject = $quotaProject; + $this->serviceAccountIdentity = $serviceAccountIdentity; + $this->universeDomain = $universeDomain; + $this->enableRegionalAccessBoundary = $enableRegionalAccessBoundary; + } + + /** + * The full uri for accessing the default token. + * + * @param string $serviceAccountIdentity [optional] Specify a service + * account identity name to use instead of "default". + * @return string + */ + public static function getTokenUri($serviceAccountIdentity = null) + { + $base = 'http://' . self::METADATA_IP . '/computeMetadata/'; + $base .= self::TOKEN_URI_PATH; + + if ($serviceAccountIdentity) { + return str_replace( + '/default/', + '/' . $serviceAccountIdentity . '/', + $base + ); + } + return $base; + } + + /** + * The full uri for accessing the default service account. + * + * @param string $serviceAccountIdentity [optional] Specify a service + * account identity name to use instead of "default". + * @return string + */ + public static function getClientNameUri($serviceAccountIdentity = null) + { + $base = 'http://' . self::METADATA_IP . '/computeMetadata/'; + $base .= self::CLIENT_ID_URI_PATH; + + if ($serviceAccountIdentity) { + return str_replace( + '/default/', + '/' . $serviceAccountIdentity . '/', + $base + ); + } + + return $base; + } + + /** + * The full uri for accesesing the default identity token. + * + * @param string $serviceAccountIdentity [optional] Specify a service + * account identity name to use instead of "default". + * @return string + */ + private static function getIdTokenUri($serviceAccountIdentity = null) + { + $base = 'http://' . self::METADATA_IP . '/computeMetadata/'; + $base .= self::ID_TOKEN_URI_PATH; + + if ($serviceAccountIdentity) { + return str_replace( + '/default/', + '/' . $serviceAccountIdentity . '/', + $base + ); + } + + return $base; + } + + /** + * The full uri for accessing the default project ID. + * + * @return string + */ + private static function getProjectIdUri() + { + $base = 'http://' . self::METADATA_IP . '/computeMetadata/'; + + return $base . self::PROJECT_ID_URI_PATH; + } + + /** + * The full uri for accessing the default universe domain. + * + * @return string + */ + private static function getUniverseDomainUri() + { + $base = 'http://' . self::METADATA_IP . '/computeMetadata/'; + + return $base . self::UNIVERSE_DOMAIN_URI_PATH; + } + + /** + * Determines if this an App Engine Flexible instance, by accessing the + * GAE_INSTANCE environment variable. + * + * @return bool true if this an App Engine Flexible Instance, false otherwise + */ + public static function onAppEngineFlexible() + { + return substr((string) getenv('GAE_INSTANCE'), 0, 4) === 'aef-'; + } + + /** + * Determines if this a GCE instance, by accessing the expected metadata + * host. + * If $httpHandler is not specified a the default HttpHandler is used. + * + * @param callable|null $httpHandler callback which delivers psr7 request + * @return bool True if this a GCEInstance, false otherwise + */ + public static function onGce(?callable $httpHandler = null) + { + $httpHandler = $httpHandler + ?: HttpHandlerFactory::build(HttpClientCache::getHttpClient()); + + $checkUri = 'http://' . self::METADATA_IP; + for ($i = 1; $i <= self::MAX_COMPUTE_PING_TRIES; $i++) { + try { + // Comment from: oauth2client/client.py + // + // Note: the explicit `timeout` below 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". + $resp = $httpHandler( + new Request( + 'GET', + $checkUri, + [ + self::FLAVOR_HEADER => 'Google', + self::$metricMetadataKey => self::getMetricsHeader('', 'mds') + ] + ), + ['timeout' => self::COMPUTE_PING_CONNECTION_TIMEOUT_S] + ); + + return $resp->getHeaderLine(self::FLAVOR_HEADER) == 'Google'; + } catch (ClientException $e) { + } catch (ServerException $e) { + } catch (RequestException $e) { + } catch (NetworkExceptionInterface $e) { + } + } + + if (!self::$checkResidency) { + return false; + } + + if (PHP_OS === 'Windows' || PHP_OS === 'WINNT') { + return self::detectResidencyWindows( + self::WINDOWS_REGISTRY_KEY_PATH . self::WINDOWS_REGISTRY_KEY_NAME + ); + } + + // Detect GCE residency on Linux + return self::detectResidencyLinux(self::GKE_PRODUCT_NAME_FILE); + } + + private static function detectResidencyLinux(string $productNameFile): bool + { + if (file_exists($productNameFile)) { + $productName = trim((string) file_get_contents($productNameFile)); + return 0 === strpos($productName, self::PRODUCT_NAME); + } + return false; + } + + private static function detectResidencyWindows(string $registryProductKey): bool + { + if (!class_exists(COM::class)) { + // the COM extension must be installed and enabled to detect Windows residency + // see https://www.php.net/manual/en/book.com.php + return false; + } + + $shell = new COM('WScript.Shell'); + $productName = null; + + try { + $productName = $shell->regRead($registryProductKey); + } catch (com_exception) { + // This means that we tried to read a key that doesn't exist on the registry + // which might mean that it is a windows instance that is not on GCE + return false; + } + + return 0 === strpos($productName, self::PRODUCT_NAME); + } + + /** + * Implements FetchAuthTokenInterface#fetchAuthToken. + * + * Fetches the auth tokens from the GCE metadata host if it is available. + * If $httpHandler is not specified a the default HttpHandler is used. + * + * @param callable|null $httpHandler callback which delivers psr7 request + * @param array $headers [optional] Headers to be inserted + * into the token endpoint request present. + * + * @return array { + * A set of auth related metadata, based on the token type. + * + * @type string $access_token for access tokens + * @type int $expires_in for access tokens + * @type string $token_type for access tokens + * @type string $id_token for ID tokens + * } + * @throws \Exception + */ + public function fetchAuthToken(?callable $httpHandler = null, array $headers = []) + { + $httpHandler = $httpHandler + ?: HttpHandlerFactory::build(HttpClientCache::getHttpClient()); + + if (!$this->hasCheckedOnGce) { + $this->isOnGce = self::onGce($httpHandler); + $this->hasCheckedOnGce = true; + } + if (!$this->isOnGce) { + return []; // return an empty array with no access token + } + + $response = $this->getFromMetadata( + $httpHandler, + $this->tokenUri, + $this->applyTokenEndpointMetrics($headers, $this->targetAudience ? 'it' : 'at') + ); + + if ($this->targetAudience) { + return $this->lastReceivedToken = ['id_token' => $response]; + } + + if (null === $json = json_decode($response, true)) { + throw new \Exception('Invalid JSON response'); + } + + $json['expires_at'] = time() + $json['expires_in']; + + // store this so we can retrieve it later + $this->lastReceivedToken = $json; + + return $json; + } + + /** + * Returns the Cache Key for the credential token. + * The format for the cache key is: + * TokenURI + * + * @return string + */ + public function getCacheKey() + { + return $this->tokenUri; + } + + /** + * @return array|null + */ + public function getLastReceivedToken() + { + if ($this->lastReceivedToken) { + if (array_key_exists('id_token', $this->lastReceivedToken)) { + return $this->lastReceivedToken; + } + + return [ + 'access_token' => $this->lastReceivedToken['access_token'], + 'expires_at' => $this->lastReceivedToken['expires_at'] + ]; + } + + return null; + } + + /** + * Get the client name from GCE metadata. + * + * Subsequent calls will return a cached value. + * + * @param callable|null $httpHandler callback which delivers psr7 request + * @return string + */ + public function getClientName(?callable $httpHandler = null) + { + if ($this->clientName) { + return $this->clientName; + } + + $httpHandler = $httpHandler + ?: HttpHandlerFactory::build(HttpClientCache::getHttpClient()); + + if (!$this->hasCheckedOnGce) { + $this->isOnGce = self::onGce($httpHandler); + $this->hasCheckedOnGce = true; + } + + if (!$this->isOnGce) { + return ''; + } + + $this->clientName = $this->getFromMetadata( + $httpHandler, + self::getClientNameUri($this->serviceAccountIdentity) + ); + + return $this->clientName; + } + + /** + * Fetch the default Project ID from compute engine. + * + * Returns null if called outside GCE. + * + * @param callable|null $httpHandler Callback which delivers psr7 request + * @return string|null + */ + public function getProjectId(?callable $httpHandler = null) + { + if ($this->projectId) { + return $this->projectId; + } + + $httpHandler = $httpHandler + ?: HttpHandlerFactory::build(HttpClientCache::getHttpClient()); + + if (!$this->hasCheckedOnGce) { + $this->isOnGce = self::onGce($httpHandler); + $this->hasCheckedOnGce = true; + } + + if (!$this->isOnGce) { + return null; + } + + $this->projectId = $this->getFromMetadata($httpHandler, self::getProjectIdUri()); + return $this->projectId; + } + + /** + * Fetch the default universe domain from the metadata server. + * + * @param callable|null $httpHandler Callback which delivers psr7 request + * @return string + */ + public function getUniverseDomain(?callable $httpHandler = null): string + { + if (null !== $this->universeDomain) { + return $this->universeDomain; + } + + $httpHandler = $httpHandler + ?: HttpHandlerFactory::build(HttpClientCache::getHttpClient()); + + if (!$this->hasCheckedOnGce) { + $this->isOnGce = self::onGce($httpHandler); + $this->hasCheckedOnGce = true; + } + + try { + $this->universeDomain = $this->getFromMetadata( + $httpHandler, + self::getUniverseDomainUri() + ); + } catch (ClientException $e) { + // If the metadata server exists, but returns a 404 for the universe domain, the auth + // libraries should safely assume this is an older metadata server running in GCU, and + // should return the default universe domain. + if (404 !== $e->getResponse()->getStatusCode()) { + throw $e; + } + $this->universeDomain = self::DEFAULT_UNIVERSE_DOMAIN; + } + + // We expect in some cases the metadata server will return an empty string for the universe + // domain. In this case, the auth library MUST return the default universe domain. + if ('' === $this->universeDomain) { + $this->universeDomain = self::DEFAULT_UNIVERSE_DOMAIN; + } + + return $this->universeDomain; + } + + /** + * Updates metadata with the authorization token. + * + * @param array $metadata metadata hashmap + * @param string $authUri optional auth uri + * @param callable|null $httpHandler callback which delivers psr7 request + * @return array updated metadata hashmap + */ + public function updateMetadata( + $metadata, + $authUri = null, + ?callable $httpHandler = null + ) { + $metadata = parent::updateMetadata($metadata, $authUri, $httpHandler); + + if ($this->enableRegionalAccessBoundary) { + $serviceAccountEmail = $this->getClientName($httpHandler); + if (preg_match('/^[^@]+@[^@]+\.[^@]+$/', $serviceAccountEmail)) { + $metadata = $this->updateRegionalAccessBoundaryMetadata( + $metadata, + $this->buildRegionalAccessBoundaryLookupUrl($serviceAccountEmail), + $this->getUniverseDomain($httpHandler), + $httpHandler, + ); + } + } + + return $metadata; + } + + /** + * Fetch the value of a GCE metadata server URI. + * + * @param callable $httpHandler An HTTP Handler to deliver PSR7 requests. + * @param string $uri The metadata URI. + * @param array $headers [optional] If present, add these headers to the token + * endpoint request. + * + * @return string + */ + private function getFromMetadata(callable $httpHandler, $uri, array $headers = []) + { + $resp = $httpHandler( + new Request( + 'GET', + $uri, + [self::FLAVOR_HEADER => 'Google'] + $headers + ) + ); + + return (string) $resp->getBody(); + } + + /** + * Get the quota project used for this API request + * + * @return string|null + */ + public function getQuotaProject() + { + return $this->quotaProject; + } + + /** + * Set whether or not we've already checked the GCE environment. + * + * @param bool $isOnGce + * + * @return void + */ + public function setIsOnGce($isOnGce) + { + // Implicitly set hasCheckedGce to true + $this->hasCheckedOnGce = true; + + // Set isOnGce + $this->isOnGce = $isOnGce; + } + + protected function getCredType(): string + { + return self::CRED_TYPE; + } +} diff --git a/Auth/src/Credentials/IAMCredentials.php b/Auth/src/Credentials/IAMCredentials.php new file mode 100644 index 000000000000..96d1df73494f --- /dev/null +++ b/Auth/src/Credentials/IAMCredentials.php @@ -0,0 +1,91 @@ +selector = $selector; + $this->token = $token; + } + + /** + * export a callback function which updates runtime metadata. + * + * @return callable updateMetadata function + */ + public function getUpdateMetadataFunc() + { + return [$this, 'updateMetadata']; + } + + /** + * Updates metadata with the appropriate header metadata. + * + * @param array $metadata metadata hashmap + * @param string $unusedAuthUri optional auth uri + * @param callable|null $httpHandler callback which delivers psr7 request + * Note: this param is unused here, only included here for + * consistency with other credentials class + * + * @return array updated metadata hashmap + */ + public function updateMetadata( + $metadata, + $unusedAuthUri = null, + ?callable $httpHandler = null + ) { + $metadata_copy = $metadata; + $metadata_copy[self::SELECTOR_KEY] = $this->selector; + $metadata_copy[self::TOKEN_KEY] = $this->token; + + return $metadata_copy; + } +} diff --git a/Auth/src/Credentials/ImpersonatedServiceAccountCredentials.php b/Auth/src/Credentials/ImpersonatedServiceAccountCredentials.php new file mode 100644 index 000000000000..a6560bf8cdf6 --- /dev/null +++ b/Auth/src/Credentials/ImpersonatedServiceAccountCredentials.php @@ -0,0 +1,342 @@ +|null + */ + protected array|null $lastReceivedToken = null; + + /** + * Instantiate an instance of ImpersonatedServiceAccountCredentials from a credentials file that + * has be created with the --impersonate-service-account flag. + * + * @param string|string[]|null $scope The scope of the access request, expressed either as an + * array or as a space-delimited string. + * @param string|array $jsonKey JSON credential file path or JSON array credentials { + * JSON credentials as an associative array. + * + * @type string $service_account_impersonation_url The URL to the service account + * @type string|FetchAuthTokenInterface $source_credentials The source credentials to impersonate + * @type int $lifetime The lifetime of the impersonated credentials + * @type string[] $delegates The delegates to impersonate + * } + * @param string|null $targetAudience The audience to request an ID token. + * @param string|string[]|null $defaultScope The scopes to be used if no "scopes" field exists + * in the `$jsonKey`. + */ + public function __construct( + string|array|null $scope, + string|array $jsonKey, + private ?string $targetAudience = null, + string|array|null $defaultScope = null, + bool $enableRegionalAccessBoundary = false + ) { + if (is_string($jsonKey)) { + if (!file_exists($jsonKey)) { + throw new InvalidArgumentException('file does not exist'); + } + $json = file_get_contents($jsonKey); + if (!$jsonKey = json_decode((string) $json, true)) { + throw new LogicException('invalid json for auth config'); + } + } + if (!array_key_exists('service_account_impersonation_url', $jsonKey)) { + throw new LogicException( + 'json key is missing the service_account_impersonation_url field' + ); + } + if (!array_key_exists('source_credentials', $jsonKey)) { + throw new LogicException('json key is missing the source_credentials field'); + } + + $jsonKeyScope = $jsonKey['scopes'] ?? null; + $scope = $scope ?: $jsonKeyScope ?: $defaultScope; + if ($scope && $targetAudience) { + throw new InvalidArgumentException( + 'Scope and targetAudience cannot both be supplied' + ); + } + if (is_array($jsonKey['source_credentials'])) { + if (!array_key_exists('type', $jsonKey['source_credentials'])) { + throw new InvalidArgumentException('json key source credentials are missing the type field'); + } + if ($targetAudience !== null + && $jsonKey['source_credentials']['type'] === 'service_account' + ) { + // Service account tokens MUST request a scope, and as this token is only used to impersonate + // an ID token, the narrowest scope we can request is `iam`. + $scope = self::IAM_SCOPE; + } + $jsonKey['source_credentials'] = match ($jsonKey['source_credentials']['type'] ?? null) { + // Do not pass $defaultScope to ServiceAccountCredentials + 'service_account' => new ServiceAccountCredentials( + scope: $scope, + jsonKey: $jsonKey['source_credentials'], + ), + 'authorized_user' => new UserRefreshCredentials($scope, $jsonKey['source_credentials']), + 'external_account' => new ExternalAccountCredentials($scope, $jsonKey['source_credentials']), + default => throw new \InvalidArgumentException('invalid value in the type field'), + }; + } + + $this->targetScope = $scope ?? []; + $this->lifetime = $jsonKey['lifetime'] ?? 3600; + $this->delegates = $jsonKey['delegates'] ?? []; + + $this->serviceAccountImpersonationUrl = $jsonKey['service_account_impersonation_url']; + $this->impersonatedServiceAccountName = $this->getImpersonatedServiceAccountNameFromUrl( + $this->serviceAccountImpersonationUrl + ); + + $this->sourceCredentials = $jsonKey['source_credentials']; + $this->enableRegionalAccessBoundary = $enableRegionalAccessBoundary; + } + + /** + * Helper function for extracting the Server Account Name from the URL saved in the account + * credentials file. + * + * @param $serviceAccountImpersonationUrl string URL from "service_account_impersonation_url" + * @return string Service account email or ID. + */ + private function getImpersonatedServiceAccountNameFromUrl( + string $serviceAccountImpersonationUrl + ): string { + $fields = explode('/', $serviceAccountImpersonationUrl); + $lastField = end($fields); + $splitter = explode(':', $lastField); + return $splitter[0]; + } + + /** + * Get the client name from the keyfile + * + * In this implementation, it will return the issuers email from the oauth token. + * + * @param callable|null $unusedHttpHandler not used by this credentials type. + * @return string Token issuer email + */ + public function getClientName(?callable $unusedHttpHandler = null) + { + return $this->impersonatedServiceAccountName; + } + + /** + * @param callable|null $httpHandler + * + * @return array { + * A set of auth related metadata, containing the following + * + * @type string $access_token + * @type int $expires_in + * @type string $scope + * @type string $token_type + * @type string $id_token + * } + */ + public function fetchAuthToken(?callable $httpHandler = null) + { + $httpHandler = $httpHandler ?? HttpHandlerFactory::build(HttpClientCache::getHttpClient()); + + // The FetchAuthTokenInterface technically does not have a "headers" argument, but all of + // the implementations do. Additionally, passing in more parameters than the function has + // defined is allowed in PHP. So we'll just ignore the phpstan error here. + // @phpstan-ignore-next-line + $authToken = $this->sourceCredentials->fetchAuthToken( + $httpHandler, + $this->applyTokenEndpointMetrics([], 'at') + ); + + $headers = $this->applyTokenEndpointMetrics([ + 'Content-Type' => 'application/json', + 'Cache-Control' => 'no-store', + 'Authorization' => sprintf('Bearer %s', $authToken['access_token'] ?? $authToken['id_token']), + ], $this->isIdTokenRequest() ? 'it' : 'at'); + + $body = match ($this->isIdTokenRequest()) { + true => [ + 'audience' => $this->targetAudience, + 'includeEmail' => true, + ], + false => [ + 'scope' => $this->targetScope, + 'delegates' => $this->delegates, + 'lifetime' => sprintf('%ss', $this->lifetime), + ] + }; + + $url = $this->serviceAccountImpersonationUrl; + if ($this->isIdTokenRequest()) { + $regex = '/serviceAccounts\/(?[^:]+):generateAccessToken$/'; + if (!preg_match($regex, $url, $matches)) { + throw new InvalidArgumentException( + 'Invalid service account impersonation URL - unable to parse service account email' + ); + } + $url = str_replace( + 'UNIVERSE_DOMAIN', + $this->getUniverseDomain(), + sprintf(self::ID_TOKEN_IMPERSONATION_URL, $matches['email']) + ); + } + + $request = new Request( + 'POST', + $url, + $headers, + (string) json_encode($body) + ); + + $response = $httpHandler($request); + $body = json_decode((string) $response->getBody(), true); + + return $this->lastReceivedToken = match ($this->isIdTokenRequest()) { + true => ['id_token' => $body['token']], + false => [ + 'access_token' => $body['accessToken'], + 'expires_at' => strtotime($body['expireTime']), + ] + }; + } + + /** + * Returns the Cache Key for the credentials + * The cache key is the same as the UserRefreshCredentials class + * + * @return string + */ + public function getCacheKey() + { + return $this->getFullCacheKey( + $this->serviceAccountImpersonationUrl . $this->sourceCredentials->getCacheKey() + ); + } + + /** + * @return array + */ + public function getLastReceivedToken() + { + return $this->lastReceivedToken; + } + + protected function getCredType(): string + { + return self::CRED_TYPE; + } + + private function isIdTokenRequest(): bool + { + return !is_null($this->targetAudience); + } + + public function getUniverseDomain(): string + { + return $this->sourceCredentials instanceof GetUniverseDomainInterface + ? $this->sourceCredentials->getUniverseDomain() + : self::DEFAULT_UNIVERSE_DOMAIN; + } + + /** + * Updates metadata with the authorization token. + * + * @param array $metadata metadata hashmap + * @param string $authUri optional auth uri + * @param callable|null $httpHandler callback which delivers psr7 request + * @return array updated metadata hashmap + */ + public function updateMetadata( + $metadata, + $authUri = null, + ?callable $httpHandler = null + ) { + $metatadata = parent::updateMetadata($metadata, $authUri, $httpHandler); + + $metatadata = $this->updateRegionalAccessBoundaryMetadata( + $metatadata, + $this->buildRegionalAccessBoundaryLookupUrl( + serviceAccountEmail: $this->impersonatedServiceAccountName + ), + $this->getUniverseDomain(), + $httpHandler, + ); + + return $metatadata; + } +} diff --git a/Auth/src/Credentials/InsecureCredentials.php b/Auth/src/Credentials/InsecureCredentials.php new file mode 100644 index 000000000000..5a2bef1c5ea7 --- /dev/null +++ b/Auth/src/Credentials/InsecureCredentials.php @@ -0,0 +1,68 @@ + '' + ]; + + /** + * Fetches the auth token. In this case it returns an empty string. + * + * @param callable|null $httpHandler + * @return array{access_token:string} A set of auth related metadata + */ + public function fetchAuthToken(?callable $httpHandler = null) + { + return $this->token; + } + + /** + * Returns the cache key. In this case it returns a null value, disabling + * caching. + * + * @return string|null + */ + public function getCacheKey() + { + return null; + } + + /** + * Fetches the last received token. In this case, it returns the same empty string + * auth token. + * + * @return array{access_token:string} + */ + public function getLastReceivedToken() + { + return $this->token; + } +} diff --git a/Auth/src/Credentials/RegionalAccessBoundaryTrait.php b/Auth/src/Credentials/RegionalAccessBoundaryTrait.php new file mode 100644 index 000000000000..ed568909708b --- /dev/null +++ b/Auth/src/Credentials/RegionalAccessBoundaryTrait.php @@ -0,0 +1,211 @@ + $headers + * @return null|array{locations: array, encodedLocations: string} + */ + private function getRegionalAccessBoundary( + string $universeDomain, + callable $httpHandler, + string $regionalAccessBoundaryUrl, + array $headers, + ): array|null { + if (!$this->enableRegionalAccessBoundary) { + // Only look up the RAB if the credentials have been configured to do so + return null; + } + + if ($universeDomain !== GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN) { + // Universe domain is not default, so RAB is not supported. + return null; + } + + if (array_key_exists('x-allowed-locations', $headers)) { + // If the headers are already set, do not set them + return null; + } + + // Return cached value if it exists + if ($cached = $this->getCachedValue($this->getCacheKey() . ':rab')) { + return $cached; + } + if (!array_key_exists('authorization', $headers)) { + // If we don't have an authorization token we can't look up the RAB + return null; + } + + if ($this->getCachedValue($this->getCacheKey() . ':rab:cooldown')) { + // We are in a cooldown period, wait until it's over + return null; + } + + $regionalAccessBoundary = $this->lookupRegionalAccessBoundary( + $httpHandler, + $regionalAccessBoundaryUrl, + $headers['authorization'] + ); + + if (null === $regionalAccessBoundary) { + // Do not save null RAB to cache. Instead, fail open and try again on a subsequent request. + return null; + } + + // Save to cache + $tbLifetime = 6 * 60 * 60; // 6-hour cache TTL + $this->setCachedValue($this->getCacheKey() . ':rab', $regionalAccessBoundary, $tbLifetime); + + return $regionalAccessBoundary; + } + + /** + * @param array $headers + * @return array + */ + private function updateRegionalAccessBoundaryMetadata( + array $headers, + string $regionalAccessBoundaryUrl, + string $universeDomain, + ?callable $httpHandler, + ): array { + $httpHandler = $httpHandler + ?: HttpHandlerFactory::build(HttpClientCache::getHttpClient()); + + $regionalAccessBoundaryInfo = $this->getRegionalAccessBoundary( + $universeDomain, + $httpHandler, + $regionalAccessBoundaryUrl, + $headers + ); + + if ($regionalAccessBoundaryInfo) { + $headers['x-allowed-locations'] = $regionalAccessBoundaryInfo['encodedLocations']; + } + + return $headers; + } + + /** + * Return the RAB lookup URL. + */ + private function buildRegionalAccessBoundaryLookupUrl( + ?string $serviceAccountEmail = null, + ?string $poolId = null, + ?string $projectNumber = null, + ): string { + $baseUrl = 'https://iamcredentials.googleapis.com/v1'; + if ($serviceAccountEmail) { + if (is_null($projectNumber) && is_null($poolId)) { + return sprintf( + '%s/projects/-/serviceAccounts/%s/allowedLocations', + $baseUrl, + $serviceAccountEmail + ); + } + } elseif ($poolId) { + if (is_null($projectNumber)) { + // Workforce Identity Pools + return sprintf( + '%s/locations/global/workforcePools/%s/allowedLocations', + $baseUrl, + $poolId + ); + } + // Workload Identity Pools + return sprintf( + '%s/projects/%s/locations/global/workloadIdentityPools/%s/allowedLocations', + $baseUrl, + $projectNumber, + $poolId + ); + } + + throw new InvalidArgumentException('Must supply $serviceAccountEmail, $poolId, or both $poolId and $projectId'); + } + + /** + * @param array $authHeader + * @return null|array{locations: array, encodedLocations: string} + */ + private function lookupRegionalAccessBoundary( + callable $httpHandler, + string $regionalAccessBoundaryUrl, + array $authHeader + ): array|null { + $request = new Request('GET', $regionalAccessBoundaryUrl); + $request = $request->withHeader('authorization', $authHeader); + try { + $response = $httpHandler($request); + } catch (RequestException | NetworkExceptionInterface $e) { + // An HTTP or network error occurred while requesting the RAB lookup + // (Guzzle 8 no longer classifies connection failures as + // RequestException). We swallow all errors here as a failed RAB + // lookup should not disrupt client authentication. + //@TODO Add debug logging + $this->initiateCooldown(); + return null; + } + + $regionalAccessBoundary = json_decode((string) $response->getBody(), true); + if (null === $regionalAccessBoundary) { + // An error occurred during the JSON parsing of the request body + // We swallow all errors here as a failed RAB lookup + // should not disrupt client authentication. + //@TODO Add debug logging + $this->initiateCooldown(); + return null; + } + + if (!array_key_exists('encodedLocations', $regionalAccessBoundary)) { + // The JSON response did not contain expected "allowLocations" + // We swallow all errors here as a failed RAB lookup + // should not disrupt client authentication. + //@TODO Add debug logging + $this->initiateCooldown(); + return null; + } + + /** @var array{locations: array, encodedLocations: string} $regionalAccessBoundary */ + return $regionalAccessBoundary; + } + + private function initiateCooldown(): void + { + $cooldownKey = $this->getCacheKey() . ':rab:cooldown'; + $attempt = $this->getCachedValue($cooldownKey . ':attempt') ?? 0; + + $cooldownBackoff = 15 * 60; // 15 minutes + $cooldownMax = 6 * 60 * 60; // 6 hours + $cooldownPeriod = min(++$attempt * $cooldownBackoff, $cooldownMax); + $this->setCachedValue( + $cooldownKey, + true, + (int) $cooldownPeriod + ); + $this->setCachedValue( + $cooldownKey . ':attempt', + $attempt, + (int) $cooldownPeriod * 2 + ); + } +} diff --git a/Auth/src/Credentials/ServiceAccountCredentials.php b/Auth/src/Credentials/ServiceAccountCredentials.php new file mode 100644 index 000000000000..7f28234307a5 --- /dev/null +++ b/Auth/src/Credentials/ServiceAccountCredentials.php @@ -0,0 +1,492 @@ +push($middleware); + * + * $client = new Client([ + * 'handler' => $stack, + * 'base_uri' => 'https://www.googleapis.com/taskqueue/v1beta2/projects/', + * 'auth' => 'google_auth' // authorize all requests + * ]); + * + * $res = $client->get('myproject/taskqueues/myqueue'); + * ``` + */ +class ServiceAccountCredentials extends CredentialsLoader implements + GetQuotaProjectInterface, + SignBlobInterface, + ProjectIdProviderInterface +{ + use ServiceAccountSignerTrait; + use RegionalAccessBoundaryTrait; + + /** + * Used in observability metric headers + * + * @var string + */ + private const CRED_TYPE = 'sa'; + private const IAM_SCOPE = 'https://www.googleapis.com/auth/iam'; + + /** + * The OAuth2 instance used to conduct authorization. + * + * @var OAuth2 + */ + protected $auth; + + /** + * The quota project associated with the JSON credentials + * + * @var string + */ + protected $quotaProject; + + /** + * @var string|null + */ + protected $projectId; + + /** + * @var array|null + */ + private $lastReceivedJwtAccessToken; + + /** + * @var bool + */ + private $useJwtAccessWithScope = false; + + /** + * @var ServiceAccountJwtAccessCredentials|null + */ + private $jwtAccessCredentials; + + /** + * @var string + */ + private string $universeDomain; + + /** + * Whether this is an ID token request or an access token request. Used when + * building the metric header. + */ + private bool $isIdTokenRequest = false; + + /** + * Create a new ServiceAccountCredentials. + * + * @param string|string[]|null $scope the scope of the access request, expressed + * either as an Array or as a space-delimited String. + * @param string|array $jsonKey JSON credential file path or JSON credentials + * as an associative array + * @param string $sub an email address account to impersonate, in situations when + * the service account has been delegated domain wide access. + * @param string $targetAudience The audience for the ID token. + * @param bool $enableRegionalAccessBoundary Lookup and include the regional access boundary header. + */ + public function __construct( + $scope, + $jsonKey, + $sub = null, + $targetAudience = null, + bool $enableRegionalAccessBoundary = false + ) { + if (is_string($jsonKey)) { + if (!file_exists($jsonKey)) { + throw new \InvalidArgumentException('file does not exist'); + } + $jsonKeyStream = file_get_contents($jsonKey); + if (!$jsonKey = json_decode((string) $jsonKeyStream, true)) { + throw new \LogicException('invalid json for auth config'); + } + } + if (!array_key_exists('client_email', $jsonKey)) { + throw new \InvalidArgumentException( + 'json key is missing the client_email field' + ); + } + if (!array_key_exists('private_key', $jsonKey)) { + throw new \InvalidArgumentException( + 'json key is missing the private_key field' + ); + } + if (array_key_exists('quota_project_id', $jsonKey)) { + $this->quotaProject = (string) $jsonKey['quota_project_id']; + } + if ($scope && $targetAudience) { + throw new InvalidArgumentException( + 'Scope and targetAudience cannot both be supplied' + ); + } + $additionalClaims = []; + if ($targetAudience) { + $additionalClaims = ['target_audience' => $targetAudience]; + $this->isIdTokenRequest = true; + } + $this->auth = new OAuth2([ + 'audience' => self::TOKEN_CREDENTIAL_URI, + 'issuer' => $jsonKey['client_email'], + 'scope' => $scope, + 'signingAlgorithm' => 'RS256', + 'signingKey' => $jsonKey['private_key'], + 'signingKeyId' => $jsonKey['private_key_id'] ?? null, + 'sub' => $sub, + 'tokenCredentialUri' => self::TOKEN_CREDENTIAL_URI, + 'additionalClaims' => $additionalClaims, + ]); + + $this->projectId = $jsonKey['project_id'] ?? null; + $this->universeDomain = $jsonKey['universe_domain'] ?? self::DEFAULT_UNIVERSE_DOMAIN; + $this->enableRegionalAccessBoundary = $enableRegionalAccessBoundary; + } + + /** + * When called, the ServiceAccountCredentials will use an instance of + * ServiceAccountJwtAccessCredentials to fetch (self-sign) an access token + * even when only scopes are supplied. Otherwise, + * ServiceAccountJwtAccessCredentials is only called when no scopes and an + * authUrl (audience) is suppled. + * + * @return void + */ + public function useJwtAccessWithScope() + { + $this->useJwtAccessWithScope = true; + } + + /** + * @param callable|null $httpHandler + * @param array $headers [optional] Headers to be inserted + * into the token endpoint request present. + * + * @return array { + * A set of auth related metadata, containing the following + * + * @type string $access_token + * @type int $expires_in + * @type string $token_type + * } + */ + public function fetchAuthToken(?callable $httpHandler = null, array $headers = []) + { + $httpHandler = $httpHandler + ?: HttpHandlerFactory::build(HttpClientCache::getHttpClient()); + + if ($this->useSelfSignedJwt()) { + $jwtCreds = $this->createJwtAccessCredentials(); + $accessToken = $jwtCreds->fetchAuthToken($httpHandler); + + if ($lastReceivedToken = $jwtCreds->getLastReceivedToken()) { + // Keep self-signed JWTs in memory as the last received token + $this->lastReceivedJwtAccessToken = $lastReceivedToken; + } + + return $accessToken; + } + + if ($this->isIdTokenRequest && $this->getUniverseDomain() !== self::DEFAULT_UNIVERSE_DOMAIN) { + $now = time(); + $jwt = Jwt::encode( + [ + 'iss' => $this->auth->getIssuer(), + 'sub' => $this->auth->getIssuer(), + 'scope' => self::IAM_SCOPE, + 'exp' => ($now + $this->auth->getExpiry()), + 'iat' => ($now - OAuth2::DEFAULT_SKEW_SECONDS), + ], + $this->auth->getSigningKey(), + $this->auth->getSigningAlgorithm(), + $this->auth->getSigningKeyId() + ); + // We create a new instance of Iam each time because the `$httpHandler` might change. + $idToken = (new Iam($httpHandler, $this->getUniverseDomain()))->generateIdToken( + $this->auth->getIssuer(), + $this->auth->getAdditionalClaims()['target_audience'], + $jwt, + $this->applyTokenEndpointMetrics($headers, 'it') + ); + return ['id_token' => $idToken]; + } + return $this->auth->fetchAuthToken( + $httpHandler, + $this->applyTokenEndpointMetrics($headers, $this->isIdTokenRequest ? 'it' : 'at') + ); + } + + /** + * Return the Cache Key for the credentials. + * For the cache key format is one of the following: + * ClientEmail.Scope[.Sub] + * ClientEmail.Audience[.Sub] + * + * @return string + */ + public function getCacheKey() + { + $scopeOrAudience = $this->auth->getScope(); + if (!$scopeOrAudience) { + $scopeOrAudience = $this->auth->getAudience(); + } + + $key = $this->auth->getIssuer() . '.' . $scopeOrAudience; + if ($sub = $this->auth->getSub()) { + $key .= '.' . $sub; + } + + return $key; + } + + /** + * @return array + */ + public function getLastReceivedToken() + { + // If self-signed JWTs are being used, fetch the last received token + // from memory. Else, fetch it from OAuth2 + return $this->useSelfSignedJwt() + ? $this->lastReceivedJwtAccessToken + : $this->auth->getLastReceivedToken(); + } + + /** + * Get the project ID from the service account keyfile. + * + * Returns null if the project ID does not exist in the keyfile. + * + * @param callable|null $httpHandler Not used by this credentials type. + * @return string|null + */ + public function getProjectId(?callable $httpHandler = null) + { + return $this->projectId; + } + + /** + * Updates metadata with the authorization token. + * + * @param array $metadata metadata hashmap + * @param string $authUri optional auth uri + * @param callable|null $httpHandler callback which delivers psr7 request + * @return array updated metadata hashmap + */ + public function updateMetadata( + $metadata, + $authUri = null, + ?callable $httpHandler = null + ) { + $metadata = $this->useSelfSignedJwt() + ? $this->updateMetadataSelfSignedJwt($metadata, $authUri, $httpHandler) + : parent::updateMetadata($metadata, $authUri, $httpHandler); + + $metadata = $this->updateRegionalAccessBoundaryMetadata( + $metadata, + $this->buildRegionalAccessBoundaryLookupUrl( + serviceAccountEmail: $this->auth->getIssuer() + ), + $this->getUniverseDomain(), + $httpHandler, + ); + + return $metadata; + } + + /** + * Updates metadata with the authorization token for SSJWTs. + * + * @param array $metadata metadata hashmap + * @param string $authUri optional auth uri + * @param callable|null $httpHandler callback which delivers psr7 request + * @return array updated metadata hashmap + */ + private function updateMetadataSelfSignedJwt( + $metadata, + $authUri = null, + ?callable $httpHandler = null + ) { + $jwtCreds = $this->createJwtAccessCredentials(); + + $metadata = $jwtCreds->updateMetadata( + $metadata, + // Prefer user-provided "scope" to "audience" + $this->auth->getScope() ? null : $authUri, + $httpHandler + ); + + if ($lastReceivedToken = $jwtCreds->getLastReceivedToken()) { + // Keep self-signed JWTs in memory as the last received token + $this->lastReceivedJwtAccessToken = $lastReceivedToken; + } + + return $metadata; + } + + /** + * @return ServiceAccountJwtAccessCredentials + */ + private function createJwtAccessCredentials() + { + if (!$this->jwtAccessCredentials) { + // Create credentials for self-signing a JWT (JwtAccess) + $credJson = [ + 'private_key' => $this->auth->getSigningKey(), + 'client_email' => $this->auth->getIssuer(), + ]; + $this->jwtAccessCredentials = new ServiceAccountJwtAccessCredentials( + $credJson, + $this->auth->getScope() + ); + } + + return $this->jwtAccessCredentials; + } + + /** + * @param string $sub an email address account to impersonate, in situations when + * the service account has been delegated domain wide access. + * @return void + */ + public function setSub($sub) + { + $this->auth->setSub($sub); + } + + /** + * Get the client name from the keyfile. + * + * In this case, it returns the keyfile's client_email key. + * + * @param callable|null $httpHandler Not used by this credentials type. + * @return string + */ + public function getClientName(?callable $httpHandler = null) + { + return $this->auth->getIssuer(); + } + + /** + * Get the private key from the keyfile. + * + * In this case, it returns the keyfile's private_key key, needed for JWT signing. + * + * @return string + */ + public function getPrivateKey() + { + return $this->auth->getSigningKey(); + } + + /** + * Get the quota project used for this API request + * + * @return string|null + */ + public function getQuotaProject() + { + return $this->quotaProject; + } + + /** + * Get the universe domain configured in the JSON credential. + * + * @return string + */ + public function getUniverseDomain(): string + { + return $this->universeDomain; + } + + protected function getCredType(): string + { + return self::CRED_TYPE; + } + + /** + * @return bool + */ + private function useSelfSignedJwt() + { + // When a sub is supplied, the user is using domain-wide delegation, which not available + // with self-signed JWTs + if (null !== $this->auth->getSub()) { + // If we are outside the GDU, we can't use domain-wide delegation + if ($this->getUniverseDomain() !== self::DEFAULT_UNIVERSE_DOMAIN) { + throw new \LogicException(sprintf( + 'Service Account subject is configured for the credential. Domain-wide ' . + 'delegation is not supported in universes other than %s.', + self::DEFAULT_UNIVERSE_DOMAIN + )); + } + return false; + } + + // Do not use self-signed JWT for ID tokens + if ($this->isIdTokenRequest) { + return false; + } + + // When true, ServiceAccountCredentials will always use JwtAccess for access tokens + if ($this->useJwtAccessWithScope) { + return true; + } + + // If the universe domain is outside the GDU, use JwtAccess for access tokens + if ($this->getUniverseDomain() !== self::DEFAULT_UNIVERSE_DOMAIN) { + return true; + } + + return is_null($this->auth->getScope()); + } +} diff --git a/Auth/src/Credentials/ServiceAccountJwtAccessCredentials.php b/Auth/src/Credentials/ServiceAccountJwtAccessCredentials.php new file mode 100644 index 000000000000..50373760b9aa --- /dev/null +++ b/Auth/src/Credentials/ServiceAccountJwtAccessCredentials.php @@ -0,0 +1,246 @@ + $jsonKey JSON credential file path or JSON credentials + * as an associative array + * @param string|string[] $scope the scope of the access request, expressed + * either as an Array or as a space-delimited String. + */ + public function __construct($jsonKey, $scope = null) + { + if (is_string($jsonKey)) { + if (!file_exists($jsonKey)) { + throw new \InvalidArgumentException('file does not exist'); + } + $jsonKeyStream = file_get_contents($jsonKey); + if (!$jsonKey = json_decode((string) $jsonKeyStream, true)) { + throw new \LogicException('invalid json for auth config'); + } + } + if (!array_key_exists('client_email', $jsonKey)) { + throw new \InvalidArgumentException( + 'json key is missing the client_email field' + ); + } + if (!array_key_exists('private_key', $jsonKey)) { + throw new \InvalidArgumentException( + 'json key is missing the private_key field' + ); + } + if (array_key_exists('quota_project_id', $jsonKey)) { + $this->quotaProject = (string) $jsonKey['quota_project_id']; + } + $this->auth = new OAuth2([ + 'issuer' => $jsonKey['client_email'], + 'sub' => $jsonKey['client_email'], + 'signingAlgorithm' => 'RS256', + 'signingKey' => $jsonKey['private_key'], + 'scope' => $scope, + ]); + + $this->projectId = $jsonKey['project_id'] ?? null; + } + + /** + * Updates metadata with the authorization token. + * + * @param array $metadata metadata hashmap + * @param string $authUri optional auth uri + * @param callable|null $httpHandler callback which delivers psr7 request + * @return array updated metadata hashmap + */ + public function updateMetadata( + $metadata, + $authUri = null, + ?callable $httpHandler = null + ) { + $scope = $this->auth->getScope(); + if (empty($authUri) && empty($scope)) { + return $metadata; + } + + $this->auth->setAudience($authUri); + + return parent::updateMetadata($metadata, $authUri, $httpHandler); + } + + /** + * Implements FetchAuthTokenInterface#fetchAuthToken. + * + * @param callable|null $httpHandler + * + * @return null|array{access_token:string} A set of auth related metadata + */ + public function fetchAuthToken(?callable $httpHandler = null) + { + $audience = $this->auth->getAudience(); + $scope = $this->auth->getScope(); + if (empty($audience) && empty($scope)) { + return null; + } + + if (!empty($audience) && !empty($scope)) { + throw new \UnexpectedValueException( + 'Cannot sign both audience and scope in JwtAccess' + ); + } + + $access_token = $this->auth->toJwt(); + + // Set the self-signed access token in OAuth2 for getLastReceivedToken + $this->auth->setAccessToken($access_token); + + return [ + 'access_token' => $access_token, + 'expires_in' => $this->auth->getExpiry(), + 'token_type' => 'Bearer' + ]; + } + + /** + * Return the cache key for the credentials. + * The format for the Cache Key one of the following: + * ClientEmail.Scope + * ClientEmail.Audience + * + * @return string + */ + public function getCacheKey() + { + $scopeOrAudience = $this->auth->getScope(); + if (!$scopeOrAudience) { + $scopeOrAudience = $this->auth->getAudience(); + } + + return $this->auth->getIssuer() . '.' . $scopeOrAudience; + } + + /** + * @return array + */ + public function getLastReceivedToken() + { + return $this->auth->getLastReceivedToken(); + } + + /** + * Get the project ID from the service account keyfile. + * + * Returns null if the project ID does not exist in the keyfile. + * + * @param callable|null $httpHandler Not used by this credentials type. + * @return string|null + */ + public function getProjectId(?callable $httpHandler = null) + { + return $this->projectId; + } + + /** + * Get the client name from the keyfile. + * + * In this case, it returns the keyfile's client_email key. + * + * @param callable|null $httpHandler Not used by this credentials type. + * @return string + */ + public function getClientName(?callable $httpHandler = null) + { + return $this->auth->getIssuer(); + } + + /** + * Get the private key from the keyfile. + * + * In this case, it returns the keyfile's private_key key, needed for JWT signing. + * + * @return string + */ + public function getPrivateKey() + { + return $this->auth->getSigningKey(); + } + + /** + * Get the quota project used for this API request + * + * @return string|null + */ + public function getQuotaProject() + { + return $this->quotaProject; + } + + protected function getCredType(): string + { + return self::CRED_TYPE; + } +} diff --git a/Auth/src/Credentials/UserRefreshCredentials.php b/Auth/src/Credentials/UserRefreshCredentials.php new file mode 100644 index 000000000000..326f6cd86a64 --- /dev/null +++ b/Auth/src/Credentials/UserRefreshCredentials.php @@ -0,0 +1,202 @@ + $jsonKey JSON credential file path or JSON credentials + * as an associative array + * @param string|null $targetAudience The audience for the ID token. + */ + public function __construct( + $scope, + $jsonKey, + ?string $targetAudience = null + ) { + if (is_string($jsonKey)) { + if (!file_exists($jsonKey)) { + throw new InvalidArgumentException('file does not exist or is unreadable'); + } + $json = file_get_contents($jsonKey); + if (!$jsonKey = json_decode((string) $json, true)) { + throw new LogicException('invalid json for auth config'); + } + } + if (!array_key_exists('client_id', $jsonKey)) { + throw new InvalidArgumentException( + 'json key is missing the client_id field' + ); + } + if (!array_key_exists('client_secret', $jsonKey)) { + throw new InvalidArgumentException( + 'json key is missing the client_secret field' + ); + } + if (!array_key_exists('refresh_token', $jsonKey)) { + throw new InvalidArgumentException( + 'json key is missing the refresh_token field' + ); + } + if ($scope && $targetAudience) { + throw new InvalidArgumentException( + 'Scope and targetAudience cannot both be supplied' + ); + } + $additionalClaims = []; + if ($targetAudience) { + $additionalClaims = ['target_audience' => $targetAudience]; + $this->isIdTokenRequest = true; + } + $this->auth = new OAuth2([ + 'clientId' => $jsonKey['client_id'], + 'clientSecret' => $jsonKey['client_secret'], + 'refresh_token' => $jsonKey['refresh_token'], + 'scope' => $scope, + 'tokenCredentialUri' => self::TOKEN_CREDENTIAL_URI, + 'additionalClaims' => $additionalClaims, + ]); + if (array_key_exists('quota_project_id', $jsonKey)) { + $this->quotaProject = (string) $jsonKey['quota_project_id']; + } + } + + /** + * @param callable|null $httpHandler + * @param array $headers [optional] Metrics headers to be inserted + * into the token endpoint request present. + * This could be passed from ImersonatedServiceAccountCredentials as it uses + * UserRefreshCredentials as source credentials. + * + * @return array { + * A set of auth related metadata, containing the following + * + * @type string $access_token + * @type int $expires_in + * @type string $scope + * @type string $token_type + * @type string $id_token + * } + */ + public function fetchAuthToken(?callable $httpHandler = null, array $headers = []) + { + return $this->auth->fetchAuthToken( + $httpHandler, + $this->applyTokenEndpointMetrics($headers, $this->isIdTokenRequest ? 'it' : 'at') + ); + } + + /** + * Return the Cache Key for the credentials. + * The format for the Cache key is one of the following: + * ClientId.Scope + * ClientId.Audience + * + * @return string + */ + public function getCacheKey() + { + $scopeOrAudience = $this->auth->getScope(); + if (!$scopeOrAudience) { + $scopeOrAudience = $this->auth->getAudience(); + } + + return $this->auth->getClientId() . '.' . $scopeOrAudience; + } + + /** + * @return array + */ + public function getLastReceivedToken() + { + return $this->auth->getLastReceivedToken(); + } + + /** + * Get the quota project used for this API request + * + * @return string|null + */ + public function getQuotaProject() + { + return $this->quotaProject; + } + + /** + * Get the granted scopes (if they exist) for the last fetched token. + * + * @return string|null + */ + public function getGrantedScope() + { + return $this->auth->getGrantedScope(); + } + + protected function getCredType(): string + { + return self::CRED_TYPE; + } +} diff --git a/Auth/src/CredentialsLoader.php b/Auth/src/CredentialsLoader.php new file mode 100644 index 000000000000..d8c2977f3f51 --- /dev/null +++ b/Auth/src/CredentialsLoader.php @@ -0,0 +1,340 @@ +|null JSON key | null + */ + public static function fromEnv() + { + $path = self::getEnv(self::ENV_VAR); + if (empty($path)) { + return null; + } + if (!file_exists($path)) { + $cause = 'file ' . $path . ' does not exist'; + throw new \DomainException(self::unableToReadEnv($cause)); + } + $jsonKey = file_get_contents($path); + + return json_decode((string) $jsonKey, true); + } + + /** + * Load a JSON key from a well known path. + * + * The well known path is OS dependent: + * + * * windows: %APPDATA%/gcloud/application_default_credentials.json + * * others: $HOME/.config/gcloud/application_default_credentials.json + * + * If the file does not exist, this returns null. + * + * @return array|null JSON key | null + */ + public static function fromWellKnownFile() + { + $rootEnv = self::isOnWindows() ? 'APPDATA' : 'HOME'; + $path = [self::getEnv($rootEnv)]; + if (!self::isOnWindows()) { + $path[] = self::NON_WINDOWS_WELL_KNOWN_PATH_BASE; + } + $path[] = self::WELL_KNOWN_PATH; + $path = implode(DIRECTORY_SEPARATOR, $path); + if (!file_exists($path)) { + return null; + } + $jsonKey = file_get_contents($path); + return json_decode((string) $jsonKey, true); + } + + /** + * Create a new Credentials instance. + * + * @deprecated This method is being 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 + * 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 recommendation + * for that method. For example, if you want to load only service accounts, + * you can create the {@see ServiceAccountCredentials} explicitly: + * + * ``` + * use Google\Auth\Credentials\ServiceAccountCredentials; + * $creds = new ServiceAccountCredentials($scopes, $json); + * ``` + * + * 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. + * + * @see https://cloud.google.com/docs/authentication/external/externally-sourced-credentials + * + * @param string|string[] $scope + * @param array $jsonKey + * @param string|string[] $defaultScope + * @param bool $enableRegionalAccessBoundary Lookup and include the regional access boundary header. + * @return ServiceAccountCredentials|UserRefreshCredentials|ImpersonatedServiceAccountCredentials|ExternalAccountCredentials|ExternalAccountAuthorizedUserCredentials + */ + public static function makeCredentials( + $scope, + array $jsonKey, + $defaultScope = null, + bool $enableRegionalAccessBoundary = false + ) { + if (!array_key_exists('type', $jsonKey)) { + throw new \InvalidArgumentException('json key is missing the type field'); + } + + if ($jsonKey['type'] == 'service_account') { + // Do not pass $defaultScope to ServiceAccountCredentials + return new ServiceAccountCredentials( + $scope, + $jsonKey, + enableRegionalAccessBoundary: $enableRegionalAccessBoundary + ); + } + + if ($jsonKey['type'] == 'authorized_user') { + $anyScope = $scope ?: $defaultScope; + return new UserRefreshCredentials($anyScope, $jsonKey); + } + + if ($jsonKey['type'] == 'impersonated_service_account') { + return new ImpersonatedServiceAccountCredentials( + $scope, + $jsonKey, + defaultScope: $defaultScope, + enableRegionalAccessBoundary: $enableRegionalAccessBoundary + ); + } + + if ($jsonKey['type'] == 'external_account') { + $anyScope = $scope ?: $defaultScope; + return new ExternalAccountCredentials($anyScope, $jsonKey, $enableRegionalAccessBoundary); + } + + if ($jsonKey['type'] == 'external_account_authorized_user') { + $anyScope = $scope ?: $defaultScope; + return new ExternalAccountAuthorizedUserCredentials($anyScope, $jsonKey); + } + + if ($jsonKey['type'] == 'external_account_authorized_user') { + $anyScope = $scope ?: $defaultScope; + return new ExternalAccountAuthorizedUserCredentials($anyScope, $jsonKey); + } + + throw new \InvalidArgumentException('invalid value in the type field'); + } + + /** + * Create an authorized HTTP Client from an instance of FetchAuthTokenInterface. + * + * @param FetchAuthTokenInterface $fetcher is used to fetch the auth token + * @param array $httpClientOptions (optional) Array of request options to apply. + * @param callable|null $httpHandler (optional) http client to fetch the token. + * @param callable|null $tokenCallback (optional) function to be called when a new token is fetched. + * @return \GuzzleHttp\Client + */ + public static function makeHttpClient( + FetchAuthTokenInterface $fetcher, + array $httpClientOptions = [], + ?callable $httpHandler = null, + ?callable $tokenCallback = null + ) { + $middleware = new Middleware\AuthTokenMiddleware( + $fetcher, + $httpHandler, + $tokenCallback + ); + $stack = \GuzzleHttp\HandlerStack::create(); + $stack->push($middleware); + + return new \GuzzleHttp\Client([ + 'handler' => $stack, + 'auth' => 'google_auth', + ] + $httpClientOptions); + } + + /** + * Create a new instance of InsecureCredentials. + * + * @return InsecureCredentials + */ + public static function makeInsecureCredentials() + { + return new InsecureCredentials(); + } + + /** + * Fetch a quota project from the environment variable + * GOOGLE_CLOUD_QUOTA_PROJECT. Return null if + * GOOGLE_CLOUD_QUOTA_PROJECT is not specified. + * + * @return string|null + */ + public static function quotaProjectFromEnv() + { + return self::getEnv(self::QUOTA_PROJECT_ENV_VAR) ?: null; + } + + /** + * Gets a callable which returns the default device certification. + * + * @throws UnexpectedValueException + * @return callable|null + */ + public static function getDefaultClientCertSource() + { + if (!$clientCertSourceJson = self::loadDefaultClientCertSourceFile()) { + return null; + } + $clientCertSourceCmd = $clientCertSourceJson['cert_provider_command']; + + return function () use ($clientCertSourceCmd) { + $cmd = array_map('escapeshellarg', $clientCertSourceCmd); + exec(implode(' ', $cmd), $output, $returnVar); + + if (0 === $returnVar) { + return implode(PHP_EOL, $output); + } + throw new RuntimeException( + '"cert_provider_command" failed with a nonzero exit code' + ); + }; + } + + /** + * Determines whether or not the default device certificate should be loaded. + * + * @return bool + */ + public static function shouldLoadClientCertSource() + { + return filter_var(self::getEnv(self::MTLS_CERT_ENV_VAR), FILTER_VALIDATE_BOOLEAN); + } + + /** + * @return array{cert_provider_command:string[]}|null + */ + private static function loadDefaultClientCertSourceFile() + { + $rootEnv = self::isOnWindows() ? 'APPDATA' : 'HOME'; + $path = sprintf('%s/%s', self::getEnv($rootEnv), self::MTLS_WELL_KNOWN_PATH); + if (!file_exists($path)) { + return null; + } + $jsonKey = file_get_contents($path); + $clientCertSourceJson = json_decode((string) $jsonKey, true); + if (!$clientCertSourceJson) { + throw new UnexpectedValueException('Invalid client cert source JSON'); + } + if (!isset($clientCertSourceJson['cert_provider_command'])) { + throw new UnexpectedValueException( + 'cert source requires "cert_provider_command"' + ); + } + if (!is_array($clientCertSourceJson['cert_provider_command'])) { + throw new UnexpectedValueException( + 'cert source expects "cert_provider_command" to be an array' + ); + } + return $clientCertSourceJson; + } + + /** + * Get the universe domain from the credential. Defaults to "googleapis.com" + * for all credential types which do not support universe domain. + * + * @return string + */ + public function getUniverseDomain(): string + { + return self::DEFAULT_UNIVERSE_DOMAIN; + } + + private static function getEnv(string $env): mixed + { + return getenv($env) ?: $_ENV[$env] ?? null; + } +} diff --git a/Auth/src/ExecutableHandler/ExecutableHandler.php b/Auth/src/ExecutableHandler/ExecutableHandler.php new file mode 100644 index 000000000000..8f5e13f4e5dc --- /dev/null +++ b/Auth/src/ExecutableHandler/ExecutableHandler.php @@ -0,0 +1,83 @@ + */ + private array $env = []; + + private ?string $output = null; + + /** + * @param array $env + */ + public function __construct( + array $env = [], + int $timeoutMs = self::DEFAULT_EXECUTABLE_TIMEOUT_MILLIS, + ) { + if (!class_exists(Process::class)) { + throw new RuntimeException(sprintf( + 'The "symfony/process" package is required to use %s.', + self::class + )); + } + $this->env = $env; + $this->timeoutMs = $timeoutMs; + } + + /** + * @param string $command + * @return int + */ + public function __invoke(string $command): int + { + $process = Process::fromShellCommandline( + $command, + null, + $this->env, + null, + ($this->timeoutMs / 1000) + ); + + try { + $process->run(); + } catch (ProcessTimedOutException $e) { + throw new ExecutableResponseError( + 'The executable failed to finish within the timeout specified.', + 'TIMEOUT_EXCEEDED' + ); + } + + $this->output = $process->getOutput() . $process->getErrorOutput(); + + return $process->getExitCode(); + } + + public function getOutput(): ?string + { + return $this->output; + } +} diff --git a/Auth/src/ExecutableHandler/ExecutableResponseError.php b/Auth/src/ExecutableHandler/ExecutableResponseError.php new file mode 100644 index 000000000000..4410902509a0 --- /dev/null +++ b/Auth/src/ExecutableHandler/ExecutableResponseError.php @@ -0,0 +1,27 @@ +|null $cacheConfig Configuration for the cache + * @param CacheItemPoolInterface $cache + */ + public function __construct( + FetchAuthTokenInterface $fetcher, + ?array $cacheConfig = null, + ?CacheItemPoolInterface $cache = null + ) { + $this->fetcher = $fetcher; + $this->cache = $cache; + $this->cacheConfig = array_merge([ + 'lifetime' => 1500, + 'prefix' => '', + 'cacheUniverseDomain' => $fetcher instanceof Credentials\GCECredentials, + ], (array) $cacheConfig); + } + + /** + * @return FetchAuthTokenInterface + */ + public function getFetcher() + { + return $this->fetcher; + } + + /** + * Implements FetchAuthTokenInterface#fetchAuthToken. + * + * Checks the cache for a valid auth token and fetches the auth tokens + * from the supplied fetcher. + * + * @param callable|null $httpHandler callback which delivers psr7 request + * @return array the response + * @throws \Exception + */ + public function fetchAuthToken(?callable $httpHandler = null) + { + if ($cached = $this->fetchAuthTokenFromCache()) { + return $cached; + } + + $auth_token = $this->fetcher->fetchAuthToken($httpHandler); + + $this->saveAuthTokenInCache($auth_token); + + return $auth_token; + } + + /** + * @return string + */ + public function getCacheKey() + { + return $this->getFullCacheKey($this->fetcher->getCacheKey()); + } + + /** + * @return array|null + */ + public function getLastReceivedToken() + { + return $this->fetcher->getLastReceivedToken(); + } + + /** + * Get the client name from the fetcher. + * + * @param callable|null $httpHandler An HTTP handler to deliver PSR7 requests. + * @return string + */ + public function getClientName(?callable $httpHandler = null) + { + if (!$this->fetcher instanceof SignBlobInterface) { + throw new \RuntimeException( + 'Credentials fetcher does not implement ' . + 'Google\Auth\SignBlobInterface' + ); + } + + return $this->fetcher->getClientName($httpHandler); + } + + /** + * Sign a blob using the fetcher. + * + * @param string $stringToSign The string to sign. + * @param bool $forceOpenSsl Require use of OpenSSL for local signing. Does + * not apply to signing done using external services. **Defaults to** + * `false`. + * @return string The resulting signature. + * @throws \RuntimeException If the fetcher does not implement + * `Google\Auth\SignBlobInterface`. + */ + public function signBlob($stringToSign, $forceOpenSsl = false) + { + if (!$this->fetcher instanceof SignBlobInterface) { + throw new \RuntimeException( + 'Credentials fetcher does not implement ' . + 'Google\Auth\SignBlobInterface' + ); + } + + // Pass the access token from cache for credentials that sign blobs + // using the IAM API. This saves a call to fetch an access token when a + // cached token exists. + if ($this->fetcher instanceof Credentials\GCECredentials + || $this->fetcher instanceof Credentials\ImpersonatedServiceAccountCredentials + ) { + $cached = $this->fetchAuthTokenFromCache(); + $accessToken = $cached['access_token'] ?? null; + return $this->fetcher->signBlob($stringToSign, $forceOpenSsl, $accessToken); + } + + return $this->fetcher->signBlob($stringToSign, $forceOpenSsl); + } + + /** + * Get the quota project used for this API request from the credentials + * fetcher. + * + * @return string|null + */ + public function getQuotaProject() + { + if ($this->fetcher instanceof GetQuotaProjectInterface) { + return $this->fetcher->getQuotaProject(); + } + + return null; + } + + /** + * Get the Project ID from the fetcher. + * + * @param callable|null $httpHandler Callback which delivers psr7 request + * @return string|null + * @throws \RuntimeException If the fetcher does not implement + * `Google\Auth\ProvidesProjectIdInterface`. + */ + public function getProjectId(?callable $httpHandler = null) + { + if (!$this->fetcher instanceof ProjectIdProviderInterface) { + throw new \RuntimeException( + 'Credentials fetcher does not implement ' . + 'Google\Auth\ProvidesProjectIdInterface' + ); + } + + // Pass the access token from cache for credentials that require an + // access token to fetch the project ID. This saves a call to fetch an + // access token when a cached token exists. + if ($this->fetcher instanceof Credentials\ExternalAccountCredentials) { + $cached = $this->fetchAuthTokenFromCache(); + $accessToken = $cached['access_token'] ?? null; + return $this->fetcher->getProjectId($httpHandler, $accessToken); + } + + return $this->fetcher->getProjectId($httpHandler); + } + + /* + * Get the Universe Domain from the fetcher. + * + * @return string + */ + public function getUniverseDomain(): string + { + if ($this->fetcher instanceof GetUniverseDomainInterface) { + if ($this->cacheConfig['cacheUniverseDomain']) { + return $this->getCachedUniverseDomain($this->fetcher); + } + return $this->fetcher->getUniverseDomain(); + } + + return GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN; + } + + /** + * Updates metadata with the authorization token. + * + * @param array $metadata metadata hashmap + * @param string $authUri optional auth uri + * @param callable|null $httpHandler callback which delivers psr7 request + * @return array updated metadata hashmap + * @throws \RuntimeException If the fetcher does not implement + * `Google\Auth\UpdateMetadataInterface`. + */ + public function updateMetadata( + $metadata, + $authUri = null, + ?callable $httpHandler = null + ) { + if (!$this->fetcher instanceof UpdateMetadataInterface) { + throw new \RuntimeException( + 'Credentials fetcher does not implement ' . + 'Google\Auth\UpdateMetadataInterface' + ); + } + + $cached = $this->fetchAuthTokenFromCache($authUri); + if ($cached) { + // Set the access token in the `Authorization` metadata header so + // the downstream call to updateMetadata know they don't need to + // fetch another token. + if (isset($cached['access_token'])) { + $metadata[self::AUTH_METADATA_KEY] = [ + 'Bearer ' . $cached['access_token'] + ]; + } elseif (isset($cached['id_token'])) { + $metadata[self::AUTH_METADATA_KEY] = [ + 'Bearer ' . $cached['id_token'] + ]; + } + } + + $newMetadata = $this->fetcher->updateMetadata( + $metadata, + $authUri, + $httpHandler + ); + + if (!$cached && $token = $this->fetcher->getLastReceivedToken()) { + $this->saveAuthTokenInCache($token, $authUri); + } + + return $newMetadata; + } + + /** + * @param string|null $authUri + * @return array|null + */ + private function fetchAuthTokenFromCache($authUri = null) + { + // Use the cached value if its available. + // + // TODO: correct caching; update the call to setCachedValue to set the expiry + // to the value returned with the auth token. + // + // TODO: correct caching; enable the cache to be cleared. + + // if $authUri is set, use it as the cache key + $cacheKey = $authUri + ? $this->getFullCacheKey($authUri) + : $this->fetcher->getCacheKey(); + + $cached = $this->getCachedValue($cacheKey); + if (is_array($cached)) { + if (empty($cached['expires_at'])) { + // If there is no expiration data, assume token is not expired. + // (for JwtAccess and ID tokens) + return $cached; + } + if ((time() + $this->eagerRefreshThresholdSeconds) < $cached['expires_at']) { + // access token is not expired + return $cached; + } + } + + return null; + } + + /** + * @param array $authToken + * @param string|null $authUri + * @return void + */ + private function saveAuthTokenInCache($authToken, $authUri = null) + { + if (isset($authToken['access_token']) || + isset($authToken['id_token'])) { + // if $authUri is set, use it as the cache key + $cacheKey = $authUri + ? $this->getFullCacheKey($authUri) + : $this->fetcher->getCacheKey(); + + $this->setCachedValue($cacheKey, $authToken); + } + } + + private function getCachedUniverseDomain(GetUniverseDomainInterface $fetcher): string + { + $cacheKey = $this->getFullCacheKey($fetcher->getCacheKey() . 'universe_domain'); // @phpstan-ignore-line + if ($universeDomain = $this->getCachedValue($cacheKey)) { + return $universeDomain; + } + + $universeDomain = $fetcher->getUniverseDomain(); + $this->setCachedValue($cacheKey, $universeDomain); + return $universeDomain; + } +} diff --git a/Auth/src/FetchAuthTokenInterface.php b/Auth/src/FetchAuthTokenInterface.php new file mode 100644 index 000000000000..fbbd8b0c9849 --- /dev/null +++ b/Auth/src/FetchAuthTokenInterface.php @@ -0,0 +1,54 @@ + a hash of auth tokens + */ + public function fetchAuthToken(?callable $httpHandler = null); + + /** + * Obtains a key that can used to cache the results of #fetchAuthToken. + * + * If the value is empty, the auth token is not cached. + * + * @return string a key that may be used to cache the auth token. + */ + public function getCacheKey(); + + /** + * Returns an associative array with the token and + * expiration time. + * + * @return null|array { + * The last received access token. + * + * @type string $access_token The access token string. + * @type int $expires_at The time the token expires as a UNIX timestamp. + * } + */ + public function getLastReceivedToken(); +} diff --git a/Auth/src/GCECache.php b/Auth/src/GCECache.php new file mode 100644 index 000000000000..d3dcd8c6cdb1 --- /dev/null +++ b/Auth/src/GCECache.php @@ -0,0 +1,82 @@ + $cacheConfig Configuration for the cache + * @param CacheItemPoolInterface $cache + */ + public function __construct( + ?array $cacheConfig = null, + ?CacheItemPoolInterface $cache = null + ) { + $this->cache = $cache; + $this->cacheConfig = array_merge([ + 'lifetime' => 1500, + 'prefix' => '', + ], (array) $cacheConfig); + } + + /** + * Caches the result of onGce so the metadata server is not called multiple + * times. + * + * @param callable|null $httpHandler callback which delivers psr7 request + * @return bool True if this a GCEInstance, false otherwise + */ + public function onGce(?callable $httpHandler = null) + { + if (is_null($this->cache)) { + return GCECredentials::onGce($httpHandler); + } + + $cacheKey = self::GCE_CACHE_KEY; + $onGce = $this->getCachedValue($cacheKey); + + if (is_null($onGce)) { + $onGce = GCECredentials::onGce($httpHandler); + $this->setCachedValue($cacheKey, $onGce); + } + + return $onGce; + } +} diff --git a/Auth/src/GetQuotaProjectInterface.php b/Auth/src/GetQuotaProjectInterface.php new file mode 100644 index 000000000000..517f062e7b16 --- /dev/null +++ b/Auth/src/GetQuotaProjectInterface.php @@ -0,0 +1,33 @@ +client = $client; + $this->logger = $logger; + } + + /** + * Accepts a PSR-7 request and an array of options and returns a PSR-7 response. + * + * @param RequestInterface $request + * @param array $options + * @return ResponseInterface + */ + public function __invoke(RequestInterface $request, array $options = []) + { + $requestEvent = null; + + if ($this->logger) { + $requestEvent = $this->requestLog($request, $options); + } + + $response = $this->client->send($request, $options); + + if ($this->logger) { + $this->responseLog($response, $requestEvent); + } + + return $response; + } + + /** + * Accepts a PSR-7 request and an array of options and returns a PromiseInterface + * + * @param RequestInterface $request + * @param array $options + * + * @return \GuzzleHttp\Promise\PromiseInterface + */ + public function async(RequestInterface $request, array $options = []) + { + $requestEvent = null; + + if ($this->logger) { + $requestEvent = $this->requestLog($request, $options); + } + + $promise = $this->client->sendAsync($request, $options); + + if ($this->logger) { + $promise->then(function (ResponseInterface $response) use ($requestEvent) { + $this->responseLog($response, $requestEvent); + return $response; + }); + } + + return $promise; + } + + /** + * @internal + * @param RequestInterface $request + * @param array $options + */ + public function requestLog(RequestInterface $request, array $options): RpcLogEvent + { + $requestEvent = new RpcLogEvent(); + + $requestEvent->method = $request->getMethod(); + $requestEvent->url = (string) $request->getUri(); + $requestEvent->headers = $request->getHeaders(); + $requestEvent->payload = $request->getBody()->getContents(); + $requestEvent->retryAttempt = $options['retryAttempt'] ?? null; + $requestEvent->serviceName = $options['serviceName'] ?? null; + $requestEvent->processId = (int) getmypid(); + $requestEvent->requestId = $options['requestId'] ?? crc32((string) spl_object_id($request) . getmypid()); + + $this->logRequest($requestEvent); + + return $requestEvent; + } + + /** + * @internal + */ + public function responseLog(ResponseInterface $response, RpcLogEvent $requestEvent): void + { + $responseEvent = new RpcLogEvent($requestEvent->milliseconds); + + $responseEvent->headers = $response->getHeaders(); + $responseEvent->payload = $response->getBody()->getContents(); + $responseEvent->status = $response->getStatusCode(); + $responseEvent->processId = $requestEvent->processId; + $responseEvent->requestId = $requestEvent->requestId; + + $this->logResponse($responseEvent); + } +} diff --git a/Auth/src/HttpHandler/Guzzle7HttpHandler.php b/Auth/src/HttpHandler/Guzzle7HttpHandler.php new file mode 100644 index 000000000000..e84f6603b447 --- /dev/null +++ b/Auth/src/HttpHandler/Guzzle7HttpHandler.php @@ -0,0 +1,21 @@ +remove('http_errors'); + $stack->unshift(Middleware::httpErrors($bodySummarizer), 'http_errors'); + $config['handler'] = $stack; + } + $client = new Client($config); + } + + $logger = ($logger === false) + ? null + : $logger ?? ApplicationDefaultCredentials::getDefaultLogger(); + + switch (ClientInterface::MAJOR_VERSION) { + case 7: + case 8: + return new Guzzle7HttpHandler($client, $logger); + default: + throw new \Exception('Version not supported'); + } + } +} diff --git a/Auth/src/Iam.php b/Auth/src/Iam.php new file mode 100644 index 000000000000..1867549f80dc --- /dev/null +++ b/Auth/src/Iam.php @@ -0,0 +1,155 @@ +httpHandler = $httpHandler + ?: HttpHandlerFactory::build(HttpClientCache::getHttpClient()); + $this->universeDomain = $universeDomain; + } + + /** + * Sign a string using the IAM signBlob API. + * + * Note that signing using IAM requires your service account to have the + * `iam.serviceAccounts.signBlob` permission, part of the "Service Account + * Token Creator" IAM role. + * + * @param string $email The service account email. + * @param string $accessToken An access token from the service account. + * @param string $stringToSign The string to be signed. + * @param array $delegates [optional] A list of service account emails to + * add to the delegate chain. If omitted, the value of `$email` will + * be used. + * @return string The signed string, base64-encoded. + */ + public function signBlob($email, $accessToken, $stringToSign, array $delegates = []) + { + $name = sprintf(self::SERVICE_ACCOUNT_NAME, $email); + $apiRoot = str_replace('UNIVERSE_DOMAIN', $this->universeDomain, self::IAM_API_ROOT_TEMPLATE); + $uri = $apiRoot . '/' . sprintf(self::SIGN_BLOB_PATH, $name); + + if ($delegates) { + foreach ($delegates as &$delegate) { + $delegate = sprintf(self::SERVICE_ACCOUNT_NAME, $delegate); + } + } else { + $delegates = [$name]; + } + + $body = [ + 'delegates' => $delegates, + 'payload' => base64_encode($stringToSign), + ]; + + $headers = [ + 'Authorization' => 'Bearer ' . $accessToken + ]; + + $request = new Psr7\Request( + 'POST', + $uri, + $headers, + Utils::streamFor((string) json_encode($body)) + ); + + $res = ($this->httpHandler)($request); + $body = json_decode((string) $res->getBody(), true); + + return $body['signedBlob']; + } + + /** + * Sign a string using the IAM signBlob API. + * + * Note that signing using IAM requires your service account to have the + * `iam.serviceAccounts.signBlob` permission, part of the "Service Account + * Token Creator" IAM role. + * + * @param string $clientEmail The service account email. + * @param string $targetAudience The audience for the ID token. + * @param string $bearerToken The token to authenticate the IAM request. + * @param array $headers [optional] Additional headers to send with the request. + * + * @return string The signed string, base64-encoded. + */ + public function generateIdToken( + string $clientEmail, + string $targetAudience, + string $bearerToken, + array $headers = [] + ): string { + $name = sprintf(self::SERVICE_ACCOUNT_NAME, $clientEmail); + $apiRoot = str_replace('UNIVERSE_DOMAIN', $this->universeDomain, self::IAM_API_ROOT_TEMPLATE); + $uri = $apiRoot . '/' . sprintf(self::GENERATE_ID_TOKEN_PATH, $name); + + $headers['Authorization'] = 'Bearer ' . $bearerToken; + + $body = [ + 'audience' => $targetAudience, + 'includeEmail' => true, + 'useEmailAzp' => true, + ]; + + $request = new Psr7\Request( + 'POST', + $uri, + $headers, + Utils::streamFor((string) json_encode($body)) + ); + + $res = ($this->httpHandler)($request); + $body = json_decode((string) $res->getBody(), true); + + return $body['token']; + } +} diff --git a/Auth/src/IamSignerTrait.php b/Auth/src/IamSignerTrait.php new file mode 100644 index 000000000000..da3c90903edf --- /dev/null +++ b/Auth/src/IamSignerTrait.php @@ -0,0 +1,72 @@ +iam; + if (!$signer) { + $signer = $this instanceof GetUniverseDomainInterface + ? new Iam($httpHandler, $this->getUniverseDomain()) + : new Iam($httpHandler); + } + + $email = $this->getClientName($httpHandler); + + if (is_null($accessToken)) { + $previousToken = $this->getLastReceivedToken(); + $accessToken = $previousToken + ? $previousToken['access_token'] + : $this->fetchAuthToken($httpHandler)['access_token']; + } + + return $signer->signBlob($email, $accessToken, $stringToSign); + } +} diff --git a/Auth/src/Logging/LoggingTrait.php b/Auth/src/Logging/LoggingTrait.php new file mode 100644 index 000000000000..0b8330d78a2c --- /dev/null +++ b/Auth/src/Logging/LoggingTrait.php @@ -0,0 +1,138 @@ + $event->timestamp, + 'severity' => strtoupper(LogLevel::DEBUG), + 'processId' => $event->processId ?? null, + 'requestId' => $event->requestId ?? null, + 'rpcName' => $event->rpcName ?? null, + ]; + + $debugEvent = array_filter($debugEvent, fn ($value) => !is_null($value)); + + $jsonPayload = [ + 'request.method' => $event->method, + 'request.url' => $event->url, + 'request.headers' => $event->headers, + 'request.payload' => $this->truncatePayload($event->payload), + 'request.jwt' => $this->getJwtToken($event->headers ?? []), + 'retryAttempt' => $event->retryAttempt + ]; + + // Remove null values + $debugEvent['jsonPayload'] = array_filter($jsonPayload, fn ($value) => !is_null($value)); + + $stringifiedEvent = json_encode($debugEvent, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + // There was an error stringifying the event, return to not break execution + if ($stringifiedEvent === false) { + return; + } + + $this->logger->debug($stringifiedEvent); + } + + /** + * @param RpcLogEvent $event + */ + private function logResponse(RpcLogEvent $event): void + { + $debugEvent = [ + 'timestamp' => $event->timestamp, + 'severity' => strtoupper(LogLevel::DEBUG), + 'processId' => $event->processId ?? null, + 'requestId' => $event->requestId ?? null, + 'jsonPayload' => [ + 'response.status' => $event->status, + 'response.headers' => $event->headers, + 'response.payload' => $this->truncatePayload($event->payload), + 'latencyMillis' => $event->latency, + ] + ]; + + // Remove null values + $debugEvent = array_filter($debugEvent, fn ($value) => !is_null($value)); + $debugEvent['jsonPayload'] = array_filter( + $debugEvent['jsonPayload'], + fn ($value) => !is_null($value) + ); + + $stringifiedEvent = json_encode($debugEvent, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + // There was an error stringifying the event, return to not break execution + if ($stringifiedEvent !== false) { + $this->logger->debug($stringifiedEvent); + } + } + + /** + * @param array $headers + * @return null|array + */ + private function getJwtToken(array $headers): null|array + { + if (empty($headers)) { + return null; + } + + $tokenHeader = $headers['Authorization'] ?? ''; + $token = str_replace('Bearer ', '', $tokenHeader); + + if (substr_count($token, '.') !== 2) { + return null; + } + + [$header, $token, $_] = explode('.', $token); + + return [ + 'header' => base64_decode($header), + 'token' => base64_decode($token) + ]; + } + + /** + * @param null|string $payload + * @return string + */ + private function truncatePayload(null|string $payload): null|string + { + $maxLength = 500; + + if (is_null($payload) || strlen($payload) <= $maxLength) { + return $payload; + } + + return substr($payload, 0, $maxLength) . '...'; + } +} diff --git a/Auth/src/Logging/RpcLogEvent.php b/Auth/src/Logging/RpcLogEvent.php new file mode 100644 index 000000000000..50e89fe2f376 --- /dev/null +++ b/Auth/src/Logging/RpcLogEvent.php @@ -0,0 +1,136 @@ + + */ + public null|array $headers = null; + + /** + * An array representation of JSON for the response or request + * + * @var null|string + */ + public null|string $payload = null; + + /** + * Status code for REST or gRPC methods + * + * @var null|int|string + */ + public null|int|string $status = null; + + /** + * The latency in milliseconds + * + * @var null|int + */ + public null|int $latency = null; + + /** + * The retry attempt number + * + * @var null|int + */ + public null|int $retryAttempt = null; + + /** + * The name of the gRPC method being called + * + * @var null|string + */ + public null|string $rpcName = null; + + /** + * The Service Name of the gRPC + * + * @var null|string $serviceName + */ + public null|string $serviceName = null; + + /** + * The Process ID for tracing logs + * + * @var null|int $processId + */ + public null|int $processId = null; + + /** + * The Request id for tracing logs + * + * @var null|int $requestId; + */ + public null|int $requestId = null; + + /** + * Creates an object with all the fields required for logging + * Passing a string representation of a timestamp calculates the difference between + * these two times and sets the latency field with the result. + * + * @param null|float $startTime (Optional) Parameter to calculate the latency + */ + public function __construct(null|float $startTime = null) + { + $this->timestamp = date(DATE_RFC3339); + + // Takes the micro time and convets it to millis + $this->milliseconds = round(microtime(true) * 1000); + + if ($startTime) { + $this->latency = (int) round($this->milliseconds - $startTime); + } + } +} diff --git a/Auth/src/Logging/StdOutLogger.php b/Auth/src/Logging/StdOutLogger.php new file mode 100644 index 000000000000..27b1f0eb3ce1 --- /dev/null +++ b/Auth/src/Logging/StdOutLogger.php @@ -0,0 +1,85 @@ + + */ + private array $levelMapping = [ + LogLevel::EMERGENCY => 7, + LogLevel::ALERT => 6, + LogLevel::CRITICAL => 5, + LogLevel::ERROR => 4, + LogLevel::WARNING => 3, + LogLevel::NOTICE => 2, + LogLevel::INFO => 1, + LogLevel::DEBUG => 0, + ]; + private int $level; + + /** + * Constructs a basic PSR-3 logger class that logs into StdOut for GCP Logging + * + * @param string $level The level of the logger instance. + */ + public function __construct(string $level = LogLevel::DEBUG) + { + $this->level = $this->getLevelFromName($level); + } + + /** + * {@inheritdoc} + */ + public function log($level, string|Stringable $message, array $context = []): void + { + if ($this->getLevelFromName($level) < $this->level) { + return; + } + + print($message . "\n"); + } + + /** + * @param string $levelName + * @return int + * @throws InvalidArgumentException + */ + private function getLevelFromName(string $levelName): int + { + if (!array_key_exists($levelName, $this->levelMapping)) { + throw new InvalidArgumentException('The level supplied to the Logger is not valid'); + } + + return $this->levelMapping[$levelName]; + } +} diff --git a/Auth/src/MetricsTrait.php b/Auth/src/MetricsTrait.php new file mode 100644 index 000000000000..8d5c03cf8b38 --- /dev/null +++ b/Auth/src/MetricsTrait.php @@ -0,0 +1,120 @@ + $metadata The metadata to update and return. + * @return array The updated metadata. + */ + protected function applyServiceApiUsageMetrics($metadata) + { + if ($credType = $this->getCredType()) { + // Add service api usage observability metrics info into metadata + // We expect upstream libries to have the metadata key populated already + $value = 'cred-type/' . $credType; + if (!isset($metadata[self::$metricMetadataKey])) { + // This case will happen only when someone invokes the updateMetadata + // method on the credentials fetcher themselves. + $metadata[self::$metricMetadataKey] = [$value]; + } elseif (is_array($metadata[self::$metricMetadataKey])) { + $metadata[self::$metricMetadataKey][0] .= ' ' . $value; + } else { + $metadata[self::$metricMetadataKey] .= ' ' . $value; + } + } + + return $metadata; + } + + /** + * @param array $metadata The metadata to update and return. + * @param string $authRequestType The auth request type. Possible values are + * `'at'`, `'it'`, `'mds'`. + * @return array The updated metadata. + */ + protected function applyTokenEndpointMetrics($metadata, $authRequestType) + { + $metricsHeader = self::getMetricsHeader($this->getCredType(), $authRequestType); + if (!isset($metadata[self::$metricMetadataKey])) { + $metadata[self::$metricMetadataKey] = $metricsHeader; + } + return $metadata; + } + + protected static function getVersion(): string + { + if (is_null(self::$version)) { + $versionFilePath = __DIR__ . '/../VERSION'; + self::$version = trim((string) file_get_contents($versionFilePath)); + } + return self::$version; + } + + protected function getCredType(): string + { + return ''; + } +} diff --git a/Auth/src/Middleware/AuthTokenMiddleware.php b/Auth/src/Middleware/AuthTokenMiddleware.php new file mode 100644 index 000000000000..b8f2c514c57d --- /dev/null +++ b/Auth/src/Middleware/AuthTokenMiddleware.php @@ -0,0 +1,163 @@ +' + */ +class AuthTokenMiddleware +{ + /** + * @var callable + */ + private $httpHandler; + + /** + * It must be an implementation of FetchAuthTokenInterface. + * It may also implement UpdateMetadataInterface allowing direct + * retrieval of auth related headers + * @var FetchAuthTokenInterface + */ + private $fetcher; + + /** + * @var ?callable + */ + private $tokenCallback; + + /** + * Creates a new AuthTokenMiddleware. + * + * @param FetchAuthTokenInterface $fetcher is used to fetch the auth token + * @param callable|null $httpHandler (optional) callback which delivers psr7 request + * @param callable|null $tokenCallback (optional) function to be called when a new token is fetched. + */ + public function __construct( + FetchAuthTokenInterface $fetcher, + ?callable $httpHandler = null, + ?callable $tokenCallback = null + ) { + $this->fetcher = $fetcher; + $this->httpHandler = $httpHandler; + $this->tokenCallback = $tokenCallback; + } + + /** + * Updates the request with an Authorization header when auth is 'google_auth'. + * + * use Google\Auth\Middleware\AuthTokenMiddleware; + * use Google\Auth\OAuth2; + * use GuzzleHttp\Client; + * use GuzzleHttp\HandlerStack; + * + * $config = [...]; + * $oauth2 = new OAuth2($config) + * $middleware = new AuthTokenMiddleware($oauth2); + * $stack = HandlerStack::create(); + * $stack->push($middleware); + * + * $client = new Client([ + * 'handler' => $stack, + * 'base_uri' => 'https://www.googleapis.com/taskqueue/v1beta2/projects/', + * 'auth' => 'google_auth' // authorize all requests + * ]); + * + * $res = $client->get('myproject/taskqueues/myqueue'); + * + * @param callable $handler + * @return \Closure + */ + public function __invoke(callable $handler) + { + return function (RequestInterface $request, array $options) use ($handler) { + // Requests using "auth"="google_auth" will be authorized. + if (!isset($options['auth']) || $options['auth'] !== 'google_auth') { + return $handler($request, $options); + } + + $request = $this->addAuthHeaders($request); + + if ($quotaProject = $this->getQuotaProject()) { + $request = $request->withHeader( + GetQuotaProjectInterface::X_GOOG_USER_PROJECT_HEADER, + $quotaProject + ); + } + + return $handler($request, $options); + }; + } + + /** + * Adds auth related headers to the request. + * + * @param RequestInterface $request + * @return RequestInterface + */ + private function addAuthHeaders(RequestInterface $request) + { + if (!$this->fetcher instanceof UpdateMetadataInterface || + ($this->fetcher instanceof FetchAuthTokenCache && + !$this->fetcher->getFetcher() instanceof UpdateMetadataInterface) + ) { + $token = $this->fetcher->fetchAuthToken(); + $request = $request->withHeader( + 'authorization', + 'Bearer ' . ($token['access_token'] ?? $token['id_token'] ?? '') + ); + } else { + $headers = $this->fetcher->updateMetadata($request->getHeaders(), null, $this->httpHandler); + $request = Utils::modifyRequest($request, ['set_headers' => $headers]); + } + + if ($this->tokenCallback && ($token = $this->fetcher->getLastReceivedToken())) { + if (array_key_exists('access_token', $token)) { + call_user_func($this->tokenCallback, $this->fetcher->getCacheKey(), $token['access_token']); + } + } + + return $request; + } + + /** + * @return string|null + */ + private function getQuotaProject() + { + if ($this->fetcher instanceof GetQuotaProjectInterface) { + return $this->fetcher->getQuotaProject(); + } + + return null; + } +} diff --git a/Auth/src/Middleware/ProxyAuthTokenMiddleware.php b/Auth/src/Middleware/ProxyAuthTokenMiddleware.php new file mode 100644 index 000000000000..2c44871f9539 --- /dev/null +++ b/Auth/src/Middleware/ProxyAuthTokenMiddleware.php @@ -0,0 +1,155 @@ +' + */ +class ProxyAuthTokenMiddleware +{ + /** + * @var callable + */ + private $httpHandler; + + /** + * @var FetchAuthTokenInterface + */ + private $fetcher; + + /** + * @var ?callable + */ + private $tokenCallback; + + /** + * Creates a new ProxyAuthTokenMiddleware. + * + * @param FetchAuthTokenInterface $fetcher is used to fetch the auth token + * @param callable|null $httpHandler (optional) callback which delivers psr7 request + * @param callable|null $tokenCallback (optional) function to be called when a new token is fetched. + */ + public function __construct( + FetchAuthTokenInterface $fetcher, + ?callable $httpHandler = null, + ?callable $tokenCallback = null + ) { + $this->fetcher = $fetcher; + $this->httpHandler = $httpHandler; + $this->tokenCallback = $tokenCallback; + } + + /** + * Updates the request with an Authorization header when auth is 'google_auth'. + * + * use Google\Auth\Middleware\ProxyAuthTokenMiddleware; + * use Google\Auth\OAuth2; + * use GuzzleHttp\Client; + * use GuzzleHttp\HandlerStack; + * + * $config = [...]; + * $oauth2 = new OAuth2($config) + * $middleware = new ProxyAuthTokenMiddleware($oauth2); + * $stack = HandlerStack::create(); + * $stack->push($middleware); + * + * $client = new Client([ + * 'handler' => $stack, + * 'base_uri' => 'https://www.googleapis.com/taskqueue/v1beta2/projects/', + * 'proxy_auth' => 'google_auth' // authorize all requests + * ]); + * + * $res = $client->get('myproject/taskqueues/myqueue'); + * + * @param callable $handler + * @return \Closure + */ + public function __invoke(callable $handler) + { + return function (RequestInterface $request, array $options) use ($handler) { + // Requests using "proxy_auth"="google_auth" will be authorized. + if (!isset($options['proxy_auth']) || $options['proxy_auth'] !== 'google_auth') { + return $handler($request, $options); + } + + $request = $request->withHeader('proxy-authorization', 'Bearer ' . $this->fetchToken()); + + if ($quotaProject = $this->getQuotaProject()) { + $request = $request->withHeader( + GetQuotaProjectInterface::X_GOOG_USER_PROJECT_HEADER, + $quotaProject + ); + } + + return $handler($request, $options); + }; + } + + /** + * Call fetcher to fetch the token. + * + * @return string|null + */ + private function fetchToken() + { + $auth_tokens = $this->fetcher->fetchAuthToken($this->httpHandler); + + if (array_key_exists('access_token', $auth_tokens)) { + // notify the callback if applicable + if ($this->tokenCallback) { + call_user_func( + $this->tokenCallback, + $this->fetcher->getCacheKey(), + $auth_tokens['access_token'] + ); + } + + return $auth_tokens['access_token']; + } + + if (array_key_exists('id_token', $auth_tokens)) { + return $auth_tokens['id_token']; + } + + return null; + } + + /** + * @return string|null; + */ + private function getQuotaProject() + { + if ($this->fetcher instanceof GetQuotaProjectInterface) { + return $this->fetcher->getQuotaProject(); + } + + return null; + } +} diff --git a/Auth/src/Middleware/ScopedAccessTokenMiddleware.php b/Auth/src/Middleware/ScopedAccessTokenMiddleware.php new file mode 100644 index 000000000000..f2f85cc1635e --- /dev/null +++ b/Auth/src/Middleware/ScopedAccessTokenMiddleware.php @@ -0,0 +1,165 @@ +' + */ +class ScopedAccessTokenMiddleware +{ + use CacheTrait; + + const DEFAULT_CACHE_LIFETIME = 1500; + + /** + * @var callable + */ + private $tokenFunc; + + /** + * @var array|string + */ + private $scopes; + + /** + * Creates a new ScopedAccessTokenMiddleware. + * + * @param callable $tokenFunc a token generator function + * @param array|string $scopes the token authentication scopes + * @param array|null $cacheConfig configuration for the cache when it's present + * @param CacheItemPoolInterface|null $cache an implementation of CacheItemPoolInterface + */ + public function __construct( + callable $tokenFunc, + $scopes, + ?array $cacheConfig = null, + ?CacheItemPoolInterface $cache = null + ) { + $this->tokenFunc = $tokenFunc; + if (!(is_string($scopes) || is_array($scopes))) { + throw new \InvalidArgumentException( + 'wants scope should be string or array' + ); + } + $this->scopes = $scopes; + + if (!is_null($cache)) { + $this->cache = $cache; + $this->cacheConfig = array_merge([ + 'lifetime' => self::DEFAULT_CACHE_LIFETIME, + 'prefix' => '', + ], $cacheConfig); + } + } + + /** + * Updates the request with an Authorization header when auth is 'scoped'. + * + * E.g this could be used to authenticate using the AppEngine + * AppIdentityService. + * + * use google\appengine\api\app_identity\AppIdentityService; + * use Google\Auth\Middleware\ScopedAccessTokenMiddleware; + * use GuzzleHttp\Client; + * use GuzzleHttp\HandlerStack; + * + * $scope = 'https://www.googleapis.com/auth/taskqueue' + * $middleware = new ScopedAccessTokenMiddleware( + * 'AppIdentityService::getAccessToken', + * $scope, + * [ 'prefix' => 'Google\Auth\ScopedAccessToken::' ], + * $cache = new Memcache() + * ); + * $stack = HandlerStack::create(); + * $stack->push($middleware); + * + * $client = new Client([ + * 'handler' => $stack, + * 'base_url' => 'https://www.googleapis.com/taskqueue/v1beta2/projects/', + * 'auth' => 'scoped' // authorize all requests + * ]); + * + * $res = $client->get('myproject/taskqueues/myqueue'); + * + * @param callable $handler + * @return \Closure + */ + public function __invoke(callable $handler) + { + return function (RequestInterface $request, array $options) use ($handler) { + // Requests using "auth"="scoped" will be authorized. + if (!isset($options['auth']) || $options['auth'] !== 'scoped') { + return $handler($request, $options); + } + + $request = $request->withHeader('authorization', 'Bearer ' . $this->fetchToken()); + + return $handler($request, $options); + }; + } + + /** + * @return string + */ + private function getCacheKey() + { + $key = null; + + if (is_string($this->scopes)) { + $key .= $this->scopes; + } elseif (is_array($this->scopes)) { + $key .= implode(':', $this->scopes); + } + + return $key; + } + + /** + * Determine if token is available in the cache, if not call tokenFunc to + * fetch it. + * + * @return string + */ + private function fetchToken() + { + $cacheKey = $this->getCacheKey(); + $cached = $this->getCachedValue($cacheKey); + + if (!empty($cached)) { + return $cached; + } + + $token = call_user_func($this->tokenFunc, $this->scopes); + $this->setCachedValue($cacheKey, $token); + + return $token; + } +} diff --git a/Auth/src/Middleware/SimpleMiddleware.php b/Auth/src/Middleware/SimpleMiddleware.php new file mode 100644 index 000000000000..69404304f8fb --- /dev/null +++ b/Auth/src/Middleware/SimpleMiddleware.php @@ -0,0 +1,92 @@ + + */ + private $config; + + /** + * Create a new Simple plugin. + * + * The configuration array expects one option + * - key: required, otherwise InvalidArgumentException is thrown + * + * @param array $config Configuration array + */ + public function __construct(array $config) + { + if (!isset($config['key'])) { + throw new \InvalidArgumentException('requires a key to have been set'); + } + + $this->config = array_merge(['key' => null], $config); + } + + /** + * Updates the request query with the developer key if auth is set to simple. + * + * use Google\Auth\Middleware\SimpleMiddleware; + * use GuzzleHttp\Client; + * use GuzzleHttp\HandlerStack; + * + * $my_key = 'is not the same as yours'; + * $middleware = new SimpleMiddleware(['key' => $my_key]); + * $stack = HandlerStack::create(); + * $stack->push($middleware); + * + * $client = new Client([ + * 'handler' => $stack, + * 'base_uri' => 'https://www.googleapis.com/discovery/v1/', + * 'auth' => 'simple' + * ]); + * + * $res = $client->get('drive/v2/rest'); + * + * @param callable $handler + * @return \Closure + */ + public function __invoke(callable $handler) + { + return function (RequestInterface $request, array $options) use ($handler) { + // Requests using "auth"="scoped" will be authorized. + if (!isset($options['auth']) || $options['auth'] !== 'simple') { + return $handler($request, $options); + } + + $query = Query::parse($request->getUri()->getQuery()); + $params = array_merge($query, $this->config); + $uri = $request->getUri()->withQuery(Query::build($params)); + $request = $request->withUri($uri); + + return $handler($request, $options); + }; + } +} diff --git a/Auth/src/OAuth2.php b/Auth/src/OAuth2.php new file mode 100644 index 000000000000..326e0980f0da --- /dev/null +++ b/Auth/src/OAuth2.php @@ -0,0 +1,1828 @@ + + */ + public static $knownSigningAlgorithms = [ + 'HS256', + 'HS512', + 'HS384', + 'RS256', + ]; + + /** + * The well known grant types. + * + * @var array + */ + public static $knownGrantTypes = [ + 'authorization_code', + 'refresh_token', + 'password', + 'client_credentials', + ]; + + /** + * - authorizationUri + * The authorization server's HTTP endpoint capable of + * authenticating the end-user and obtaining authorization. + * + * @var ?UriInterface + */ + private $authorizationUri; + + /** + * - tokenCredentialUri + * The authorization server's HTTP endpoint capable of issuing + * tokens and refreshing expired tokens. + * + * @var UriInterface + */ + private $tokenCredentialUri; + + /** + * The redirection URI used in the initial request. + * + * @var ?string + */ + private $redirectUri; + + /** + * A unique identifier issued to the client to identify itself to the + * authorization server. + * + * @var string + */ + private $clientId; + + /** + * A shared symmetric secret issued by the authorization server, which is + * used to authenticate the client. + * + * @var string + */ + private $clientSecret; + + /** + * The resource owner's username. + * + * @var ?string + */ + private $username; + + /** + * The resource owner's password. + * + * @var ?string + */ + private $password; + + /** + * The scope of the access request, expressed either as an Array or as a + * space-delimited string. + * + * @var ?array + */ + private $scope; + + /** + * An arbitrary string designed to allow the client to maintain state. + * + * @var string + */ + private $state; + + /** + * The authorization code issued to this client. + * + * Only used by the authorization code access grant type. + * + * @var ?string + */ + private $code; + + /** + * The issuer ID when using assertion profile. + * + * @var ?string + */ + private $issuer; + + /** + * The target audience for assertions. + * + * @var string + */ + private $audience; + + /** + * The target sub when issuing assertions. + * + * @var string + */ + private $sub; + + /** + * The number of seconds assertions are valid for. + * + * @var int + */ + private $expiry; + + /** + * The signing key when using assertion profile. + * + * @var ?string + */ + private $signingKey; + + /** + * The signing key id when using assertion profile. Param kid in jwt header + * + * @var string + */ + private $signingKeyId; + + /** + * The signing algorithm when using an assertion profile. + * + * @var ?string + */ + private $signingAlgorithm; + + /** + * The refresh token associated with the access token to be refreshed. + * + * @var ?string + */ + private $refreshToken; + + /** + * The current access token. + * + * @var string + */ + private $accessToken; + + /** + * The current ID token. + * + * @var string + */ + private $idToken; + + /** + * The scopes granted to the current access token + * + * @var string + */ + private $grantedScope; + + /** + * The lifetime in seconds of the current access token. + * + * @var ?int + */ + private $expiresIn; + + /** + * The expiration time of the access token as a number of seconds since the + * unix epoch. + * + * @var ?int + */ + private $expiresAt; + + /** + * The issue time of the access token as a number of seconds since the unix + * epoch. + * + * @var ?int + */ + private $issuedAt; + + /** + * The current grant type. + * + * @var ?string + */ + private $grantType; + + /** + * When using an extension grant type, this is the set of parameters used by + * that extension. + * + * @var array + */ + private $extensionParams; + + /** + * When using the toJwt function, these claims will be added to the JWT + * payload. + * + * @var array + */ + private $additionalClaims; + + /** + * The code verifier for PKCE for OAuth 2.0. When set, the authorization + * URI will contain the Code Challenge and Code Challenge Method querystring + * parameters, and the token URI will contain the Code Verifier parameter. + * + * @see https://datatracker.ietf.org/doc/html/rfc7636 + * @var ?string + */ + private $codeVerifier; + + /** + * For STS requests. + * A URI that indicates the target service or resource where the client + * intends to use the requested security token. + */ + private ?string $resource; + + /** + * For STS requests. + * A fetcher for the "subject_token", which is a security token that + * represents the identity of the party on behalf of whom the request is + * being made. + */ + private ?ExternalAccountCredentialSourceInterface $subjectTokenFetcher; + + /** + * For STS requests. + * An identifier, that indicates the type of the security token in the + * subjectToken parameter. + */ + private ?string $subjectTokenType; + + /** + * For STS requests. + * A security token that represents the identity of the acting party. + */ + private ?string $actorToken; + + /** + * For STS requests. + * An identifier that indicates the type of the security token in the + * actorToken parameter. + */ + private ?string $actorTokenType; + + /** + * From STS response. + * An identifier for the representation of the issued security token. + */ + private ?string $issuedTokenType = null; + + /** + * From STS response. + * An identifier for the representation of the issued security token. + * + * @var array + */ + private array $additionalOptions; + + /** + * Create a new OAuthCredentials. + * + * The configuration array accepts various options + * + * - authorizationUri + * The authorization server's HTTP endpoint capable of + * authenticating the end-user and obtaining authorization. + * + * - tokenCredentialUri + * The authorization server's HTTP endpoint capable of issuing + * tokens and refreshing expired tokens. + * + * - clientId + * A unique identifier issued to the client to identify itself to the + * authorization server. + * + * - clientSecret + * A shared symmetric secret issued by the authorization server, + * which is used to authenticate the client. + * + * - scope + * The scope of the access request, expressed either as an Array + * or as a space-delimited String. + * + * - state + * An arbitrary string designed to allow the client to maintain state. + * + * - redirectUri + * The redirection URI used in the initial request. + * + * - username + * The resource owner's username. + * + * - password + * The resource owner's password. + * + * - issuer + * Issuer ID when using assertion profile + * + * - audience + * Target audience for assertions + * + * - expiry + * Number of seconds assertions are valid for + * + * - signingKey + * Signing key when using assertion profile + * + * - signingKeyId + * Signing key id when using assertion profile + * + * - refreshToken + * The refresh token associated with the access token + * to be refreshed. + * + * - accessToken + * The current access token for this client. + * + * - idToken + * The current ID token for this client. + * + * - extensionParams + * When using an extension grant type, this is the set of parameters used + * by that extension. + * + * - codeVerifier + * The code verifier for PKCE for OAuth 2.0. + * + * - resource + * The target service or resource where the client ntends to use the + * requested security token. + * + * - subjectTokenFetcher + * A fetcher for the "subject_token", which is a security token that + * represents the identity of the party on behalf of whom the request is + * being made. + * + * - subjectTokenType + * An identifier that indicates the type of the security token in the + * subjectToken parameter. + * + * - actorToken + * A security token that represents the identity of the acting party. + * + * - actorTokenType + * An identifier for the representation of the issued security token. + * + * @param array $config Configuration array + */ + public function __construct(array $config) + { + $opts = array_merge([ + 'expiry' => self::DEFAULT_EXPIRY_SECONDS, + 'extensionParams' => [], + 'authorizationUri' => null, + 'redirectUri' => null, + 'tokenCredentialUri' => null, + 'state' => null, + 'username' => null, + 'password' => null, + 'clientId' => null, + 'clientSecret' => null, + 'issuer' => null, + 'sub' => null, + 'audience' => null, + 'signingKey' => null, + 'signingKeyId' => null, + 'signingAlgorithm' => null, + 'scope' => null, + 'additionalClaims' => [], + 'codeVerifier' => null, + 'resource' => null, + 'subjectTokenFetcher' => null, + 'subjectTokenType' => null, + 'actorToken' => null, + 'actorTokenType' => null, + 'additionalOptions' => [], + ], $config); + + $this->setAuthorizationUri($opts['authorizationUri']); + $this->setRedirectUri($opts['redirectUri']); + $this->setTokenCredentialUri($opts['tokenCredentialUri']); + $this->setState($opts['state']); + $this->setUsername($opts['username']); + $this->setPassword($opts['password']); + $this->setClientId($opts['clientId']); + $this->setClientSecret($opts['clientSecret']); + $this->setIssuer($opts['issuer']); + $this->setSub($opts['sub']); + $this->setExpiry($opts['expiry']); + $this->setAudience($opts['audience']); + $this->setSigningKey($opts['signingKey']); + $this->setSigningKeyId($opts['signingKeyId']); + $this->setSigningAlgorithm($opts['signingAlgorithm']); + $this->setScope($opts['scope']); + $this->setExtensionParams($opts['extensionParams']); + $this->setAdditionalClaims($opts['additionalClaims']); + $this->setCodeVerifier($opts['codeVerifier']); + + // for STS + $this->resource = $opts['resource']; + $this->subjectTokenFetcher = $opts['subjectTokenFetcher']; + $this->subjectTokenType = $opts['subjectTokenType']; + $this->actorToken = $opts['actorToken']; + $this->actorTokenType = $opts['actorTokenType']; + $this->additionalOptions = $opts['additionalOptions']; + + $this->updateToken($opts); + } + + /** + * Verifies the idToken if present. + * + * - if none is present, return null + * - if present, but invalid, raises DomainException. + * - otherwise returns the payload in the idtoken as a PHP object. + * + * The behavior of this method varies depending on the version of + * `firebase/php-jwt` you are using. In versions 6.0 and above, you cannot + * provide multiple $allowed_algs, and instead must provide an array of Key + * objects as the $publicKey. + * + * @param string|Key|Key[] $publicKey The public key to use to authenticate the token + * @param string|array $allowed_algs algorithm or array of supported verification algorithms. + * Providing more than one algorithm will throw an exception. + * @throws \DomainException if the token is missing an audience. + * @throws \DomainException if the audience does not match the one set in + * the OAuth2 class instance. + * @throws \UnexpectedValueException If the token is invalid + * @throws \InvalidArgumentException If more than one value for allowed_algs is supplied + * @throws \Firebase\JWT\SignatureInvalidException If the signature is invalid. + * @throws \Firebase\JWT\BeforeValidException If the token is not yet valid. + * @throws \Firebase\JWT\ExpiredException If the token has expired. + * @return null|object + */ + public function verifyIdToken($publicKey = null, $allowed_algs = []) + { + $idToken = $this->getIdToken(); + if (is_null($idToken)) { + return null; + } + + $resp = $this->jwtDecode($idToken, $publicKey, $allowed_algs); + if (!property_exists($resp, 'aud')) { + throw new \DomainException('No audience found the id token'); + } + if ($resp->aud != $this->getAudience()) { + throw new \DomainException('Wrong audience present in the id token'); + } + + return $resp; + } + + /** + * Obtains the encoded jwt from the instance data. + * + * @param array $config array optional configuration parameters + * @return string + */ + public function toJwt(array $config = []) + { + if (is_null($this->getSigningKey())) { + throw new \DomainException('No signing key available'); + } + if (is_null($this->getSigningAlgorithm())) { + throw new \DomainException('No signing algorithm specified'); + } + $now = time(); + + $opts = array_merge([ + 'skew' => self::DEFAULT_SKEW_SECONDS, + ], $config); + + $assertion = [ + 'iss' => $this->getIssuer(), + 'exp' => ($now + $this->getExpiry()), + 'iat' => ($now - $opts['skew']), + ]; + foreach ($assertion as $k => $v) { + if (is_null($v)) { + throw new \DomainException($k . ' should not be null'); + } + } + if (!(is_null($this->getAudience()))) { + $assertion['aud'] = $this->getAudience(); + } + + if (!(is_null($this->getScope()))) { + $assertion['scope'] = $this->getScope(); + } + + if (empty($assertion['scope']) && empty($assertion['aud'])) { + throw new \DomainException('one of scope or aud should not be null'); + } + + if (!(is_null($this->getSub()))) { + $assertion['sub'] = $this->getSub(); + } + $assertion += $this->getAdditionalClaims(); + + return JWT::encode( + $assertion, + $this->getSigningKey(), + $this->getSigningAlgorithm(), + $this->getSigningKeyId() + ); + } + + /** + * Generates a request for token credentials. + * + * @param callable|null $httpHandler callback which delivers psr7 request + * @param array $headers [optional] Additional headers to pass to + * the token endpoint request. + * @return RequestInterface the authorization Url. + */ + public function generateCredentialsRequest(?callable $httpHandler = null, array $headers = []) + { + $uri = $this->getTokenCredentialUri(); + if (is_null($uri)) { + throw new \DomainException('No token credential URI was set.'); + } + + $grantType = $this->getGrantType(); + $params = ['grant_type' => $grantType]; + switch ($grantType) { + case 'authorization_code': + $params['code'] = $this->getCode(); + $params['redirect_uri'] = $this->getRedirectUri(); + if ($this->codeVerifier) { + $params['code_verifier'] = $this->codeVerifier; + } + $this->addClientCredentials($params); + break; + case 'password': + $params['username'] = $this->getUsername(); + $params['password'] = $this->getPassword(); + $this->addClientCredentials($params); + break; + case 'refresh_token': + $params['refresh_token'] = $this->getRefreshToken(); + if (isset($this->getAdditionalClaims()['target_audience'])) { + $params['target_audience'] = $this->getAdditionalClaims()['target_audience']; + } + $this->addClientCredentials($params); + break; + case self::JWT_URN: + $params['assertion'] = $this->toJwt(); + break; + case self::STS_URN: + $token = $this->subjectTokenFetcher->fetchSubjectToken($httpHandler); + $params['subject_token'] = $token; + $params['subject_token_type'] = $this->subjectTokenType; + $params += array_filter([ + 'resource' => $this->resource, + 'audience' => $this->audience, + 'scope' => $this->getScope(), + 'requested_token_type' => self::STS_REQUESTED_TOKEN_TYPE, + 'actor_token' => $this->actorToken, + 'actor_token_type' => $this->actorTokenType, + ]); + if ($this->additionalOptions) { + $params['options'] = json_encode($this->additionalOptions); + } + break; + default: + if (!is_null($this->getRedirectUri())) { + # Grant type was supposed to be 'authorization_code', as there + # is a redirect URI. + throw new \DomainException('Missing authorization code'); + } + unset($params['grant_type']); + if (!is_null($grantType)) { + $params['grant_type'] = $grantType; + } + $params = array_merge($params, $this->getExtensionParams()); + } + + $headers = [ + 'Cache-Control' => 'no-store', + 'Content-Type' => 'application/x-www-form-urlencoded', + ] + $headers; + + return new Request( + 'POST', + $uri, + $headers, + Query::build($params) + ); + } + + /** + * Fetches the auth tokens based on the current state. + * + * @param callable|null $httpHandler callback which delivers psr7 request + * @param array $headers [optional] If present, add these headers to the token + * endpoint request. + * @return array the response + */ + public function fetchAuthToken(?callable $httpHandler = null, array $headers = []) + { + if (is_null($httpHandler)) { + $httpHandler = HttpHandlerFactory::build(HttpClientCache::getHttpClient()); + } + + $response = $httpHandler($this->generateCredentialsRequest($httpHandler, $headers)); + $credentials = $this->parseTokenResponse($response); + $this->updateToken($credentials); + if (isset($credentials['scope'])) { + $this->setGrantedScope($credentials['scope']); + } + + return $credentials; + } + + /** + * + * Obtains a key that can used to cache the results of #fetchAuthToken. + * + * The key is derived from the scopes. + * + * @deprecated + * @return ?string a key that may be used to cache the auth token. + */ + public function getCacheKey() + { + if (is_array($this->scope)) { + return implode(':', $this->scope); + } + + if ($this->audience) { + return $this->audience; + } + + // If scope has not set, return null to indicate no caching. + return null; + } + + /** + * Gets this instance's SubjectTokenFetcher + * + * @return null|ExternalAccountCredentialSourceInterface + */ + public function getSubjectTokenFetcher(): ?ExternalAccountCredentialSourceInterface + { + return $this->subjectTokenFetcher; + } + + /** + * Parses the fetched tokens. + * + * @param ResponseInterface $resp the response. + * @return array the tokens parsed from the response body. + * @throws \Exception + */ + public function parseTokenResponse(ResponseInterface $resp) + { + $body = (string) $resp->getBody(); + if ($resp->hasHeader('Content-Type') && + $resp->getHeaderLine('Content-Type') == 'application/x-www-form-urlencoded' + ) { + $res = []; + parse_str($body, $res); + + return $res; + } + + // Assume it's JSON; if it's not throw an exception + if (null === $res = json_decode($body, true)) { + throw new \Exception('Invalid JSON response'); + } + + return $res; + } + + /** + * Updates an OAuth 2.0 client. + * + * Example: + * ``` + * $oauth->updateToken([ + * 'refresh_token' => 'n4E9O119d', + * 'access_token' => 'FJQbwq9', + * 'expires_in' => 3600 + * ]); + * ``` + * + * @param array $config + * The configuration parameters related to the token. + * + * - refresh_token + * The refresh token associated with the access token + * to be refreshed. + * + * - access_token + * The current access token for this client. + * + * - id_token + * The current ID token for this client. + * + * - expires_in + * The time in seconds until access token expiration. + * + * - expires_at + * The time as an integer number of seconds since the Epoch + * + * - issued_at + * The timestamp that the token was issued at. + * @return void + */ + public function updateToken(array $config) + { + $opts = array_merge([ + 'extensionParams' => [], + 'access_token' => null, + 'id_token' => null, + 'expires_in' => null, + 'expires_at' => null, + 'issued_at' => null, + 'scope' => null, + ], $config); + + $this->setExpiresAt($opts['expires_at']); + $this->setExpiresIn($opts['expires_in']); + // By default, the token is issued at `Time.now` when `expiresIn` is set, + // but this can be used to supply a more precise time. + if (!is_null($opts['issued_at'])) { + $this->setIssuedAt($opts['issued_at']); + } + + $this->setAccessToken($opts['access_token']); + $this->setIdToken($opts['id_token']); + + // The refresh token should only be updated if a value is explicitly + // passed in, as some access token responses do not include a refresh + // token. + if (array_key_exists('refresh_token', $opts)) { + $this->setRefreshToken($opts['refresh_token']); + } + + // Required for STS response. An identifier for the representation of + // the issued security token. + if (array_key_exists('issued_token_type', $opts)) { + $this->issuedTokenType = $opts['issued_token_type']; + } + } + + /** + * Builds the authorization Uri that the user should be redirected to. + * + * @param array $config configuration options that customize the return url. + * @return UriInterface the authorization Url. + * @throws InvalidArgumentException + */ + public function buildFullAuthorizationUri(array $config = []) + { + if (is_null($this->getAuthorizationUri())) { + throw new InvalidArgumentException( + 'requires an authorizationUri to have been set' + ); + } + + $params = array_merge([ + 'response_type' => 'code', + 'access_type' => 'offline', + 'client_id' => $this->clientId, + 'redirect_uri' => $this->redirectUri, + 'state' => $this->state, + 'scope' => $this->getScope(), + ], $config); + + // Validate the auth_params + if (is_null($params['client_id'])) { + throw new InvalidArgumentException( + 'missing the required client identifier' + ); + } + if (is_null($params['redirect_uri'])) { + throw new InvalidArgumentException('missing the required redirect URI'); + } + if (!empty($params['prompt']) && !empty($params['approval_prompt'])) { + throw new InvalidArgumentException( + 'prompt and approval_prompt are mutually exclusive' + ); + } + if ($this->codeVerifier) { + $params['code_challenge'] = $this->getCodeChallenge($this->codeVerifier); + $params['code_challenge_method'] = $this->getCodeChallengeMethod(); + } + + // Construct the uri object; return it if it is valid. + $result = clone $this->authorizationUri; + $existingParams = Query::parse($result->getQuery()); + + $result = $result->withQuery( + Query::build(array_merge($existingParams, $params)) + ); + + if ($result->getScheme() != 'https') { + throw new InvalidArgumentException( + 'Authorization endpoint must be protected by TLS' + ); + } + + return $result; + } + + /** + * @return string|null + */ + public function getCodeVerifier(): ?string + { + return $this->codeVerifier; + } + + /** + * A cryptographically random string that is used to correlate the + * authorization request to the token request. + * + * The code verifier for PKCE for OAuth 2.0. When set, the authorization + * URI will contain the Code Challenge and Code Challenge Method querystring + * parameters, and the token URI will contain the Code Verifier parameter. + * + * @see https://datatracker.ietf.org/doc/html/rfc7636 + * + * @param string|null $codeVerifier + */ + public function setCodeVerifier(?string $codeVerifier): void + { + $this->codeVerifier = $codeVerifier; + } + + /** + * Generates a random 128-character string for the "code_verifier" parameter + * in PKCE for OAuth 2.0. This is a cryptographically random string that is + * determined using random_int, hashed using "hash" and sha256, and base64 + * encoded. + * + * When this method is called, the code verifier is set on the object. + * + * @return string + */ + public function generateCodeVerifier(): string + { + return $this->codeVerifier = $this->generateRandomString(128); + } + + private function getCodeChallenge(string $randomString): string + { + return rtrim(strtr(base64_encode(hash('sha256', $randomString, true)), '+/', '-_'), '='); + } + + private function getCodeChallengeMethod(): string + { + return 'S256'; + } + + private function generateRandomString(int $length): string + { + $validChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~'; + $validCharsLen = strlen($validChars); + $str = ''; + $i = 0; + while ($i++ < $length) { + $str .= $validChars[random_int(0, $validCharsLen - 1)]; + } + return $str; + } + + /** + * Sets the authorization server's HTTP endpoint capable of authenticating + * the end-user and obtaining authorization. + * + * @param string $uri + * @return void + */ + public function setAuthorizationUri($uri) + { + $this->authorizationUri = $this->coerceUri($uri); + } + + /** + * Gets the authorization server's HTTP endpoint capable of authenticating + * the end-user and obtaining authorization. + * + * @return ?UriInterface + */ + public function getAuthorizationUri() + { + return $this->authorizationUri; + } + + /** + * Gets the authorization server's HTTP endpoint capable of issuing tokens + * and refreshing expired tokens. + * + * @return ?UriInterface + */ + public function getTokenCredentialUri() + { + return $this->tokenCredentialUri; + } + + /** + * Sets the authorization server's HTTP endpoint capable of issuing tokens + * and refreshing expired tokens. + * + * @param string $uri + * @return void + */ + public function setTokenCredentialUri($uri) + { + $this->tokenCredentialUri = $this->coerceUri($uri); + } + + /** + * Gets the redirection URI used in the initial request. + * + * @return ?string + */ + public function getRedirectUri() + { + return $this->redirectUri; + } + + /** + * Sets the redirection URI used in the initial request. + * + * @param ?string $uri + * @return void + */ + public function setRedirectUri($uri) + { + if (is_null($uri)) { + $this->redirectUri = null; + + return; + } + // redirect URI must be absolute + if (!$this->isAbsoluteUri($uri)) { + // "postmessage" is a reserved URI string in Google-land + // @see https://developers.google.com/identity/sign-in/web/server-side-flow + if ('postmessage' !== (string) $uri) { + throw new InvalidArgumentException( + 'Redirect URI must be absolute' + ); + } + } + $this->redirectUri = (string) $uri; + } + + /** + * Gets the scope of the access requests as a space-delimited String. + * + * @return ?string + */ + public function getScope() + { + if (is_null($this->scope)) { + return $this->scope; + } + + return implode(' ', $this->scope); + } + + /** + * Gets the subject token type + * + * @return ?string + */ + public function getSubjectTokenType(): ?string + { + return $this->subjectTokenType; + } + + /** + * Sets the scope of the access request, expressed either as an Array or as + * a space-delimited String. + * + * @param string|array|null $scope + * @return void + * @throws InvalidArgumentException + */ + public function setScope($scope) + { + if (is_null($scope)) { + $this->scope = null; + } elseif (is_string($scope)) { + $this->scope = explode(' ', $scope); + } elseif (is_array($scope)) { + foreach ($scope as $s) { + $pos = strpos($s, ' '); + if ($pos !== false) { + throw new InvalidArgumentException( + 'array scope values should not contain spaces' + ); + } + } + $this->scope = $scope; + } else { + throw new InvalidArgumentException( + 'scopes should be a string or array of strings' + ); + } + } + + /** + * Gets the current grant type. + * + * @return ?string + */ + public function getGrantType() + { + if (!is_null($this->grantType)) { + return $this->grantType; + } + + // Returns the inferred grant type, based on the current object instance + // state. + if (!is_null($this->code)) { + return 'authorization_code'; + } + + if (!is_null($this->refreshToken)) { + return 'refresh_token'; + } + + if (!is_null($this->username) && !is_null($this->password)) { + return 'password'; + } + + if (!is_null($this->issuer) && !is_null($this->signingKey)) { + return self::JWT_URN; + } + + if (!is_null($this->subjectTokenFetcher) && !is_null($this->subjectTokenType)) { + return self::STS_URN; + } + + return null; + } + + /** + * Sets the current grant type. + * + * @param string $grantType + * @return void + * @throws InvalidArgumentException + */ + public function setGrantType($grantType) + { + if (in_array($grantType, self::$knownGrantTypes)) { + $this->grantType = $grantType; + } else { + // validate URI + if (!$this->isAbsoluteUri($grantType)) { + throw new InvalidArgumentException( + 'invalid grant type' + ); + } + $this->grantType = (string) $grantType; + } + } + + /** + * Gets an arbitrary string designed to allow the client to maintain state. + * + * @return string + */ + public function getState() + { + return $this->state; + } + + /** + * Sets an arbitrary string designed to allow the client to maintain state. + * + * @param string $state + * @return void + */ + public function setState($state) + { + $this->state = $state; + } + + /** + * Gets the authorization code issued to this client. + * + * @return string + */ + public function getCode() + { + return $this->code; + } + + /** + * Sets the authorization code issued to this client. + * + * @param string $code + * @return void + */ + public function setCode($code) + { + $this->code = $code; + } + + /** + * Gets the resource owner's username. + * + * @return string + */ + public function getUsername() + { + return $this->username; + } + + /** + * Sets the resource owner's username. + * + * @param string $username + * @return void + */ + public function setUsername($username) + { + $this->username = $username; + } + + /** + * Gets the resource owner's password. + * + * @return string + */ + public function getPassword() + { + return $this->password; + } + + /** + * Sets the resource owner's password. + * + * @param string $password + * @return void + */ + public function setPassword($password) + { + $this->password = $password; + } + + /** + * Sets a unique identifier issued to the client to identify itself to the + * authorization server. + * + * @return string + */ + public function getClientId() + { + return $this->clientId; + } + + /** + * Sets a unique identifier issued to the client to identify itself to the + * authorization server. + * + * @param string $clientId + * @return void + */ + public function setClientId($clientId) + { + $this->clientId = $clientId; + } + + /** + * Gets a shared symmetric secret issued by the authorization server, which + * is used to authenticate the client. + * + * @return string + */ + public function getClientSecret() + { + return $this->clientSecret; + } + + /** + * Sets a shared symmetric secret issued by the authorization server, which + * is used to authenticate the client. + * + * @param string $clientSecret + * @return void + */ + public function setClientSecret($clientSecret) + { + $this->clientSecret = $clientSecret; + } + + /** + * Gets the Issuer ID when using assertion profile. + * + * @return ?string + */ + public function getIssuer() + { + return $this->issuer; + } + + /** + * Sets the Issuer ID when using assertion profile. + * + * @param string $issuer + * @return void + */ + public function setIssuer($issuer) + { + $this->issuer = $issuer; + } + + /** + * Gets the target sub when issuing assertions. + * + * @return ?string + */ + public function getSub() + { + return $this->sub; + } + + /** + * Sets the target sub when issuing assertions. + * + * @param string $sub + * @return void + */ + public function setSub($sub) + { + $this->sub = $sub; + } + + /** + * Gets the target audience when issuing assertions. + * + * @return ?string + */ + public function getAudience() + { + return $this->audience; + } + + /** + * Sets the target audience when issuing assertions. + * + * @param string $audience + * @return void + */ + public function setAudience($audience) + { + $this->audience = $audience; + } + + /** + * Gets the signing key when using an assertion profile. + * + * @return ?string + */ + public function getSigningKey() + { + return $this->signingKey; + } + + /** + * Sets the signing key when using an assertion profile. + * + * @param string $signingKey + * @return void + */ + public function setSigningKey($signingKey) + { + $this->signingKey = $signingKey; + } + + /** + * Gets the signing key id when using an assertion profile. + * + * @return ?string + */ + public function getSigningKeyId() + { + return $this->signingKeyId; + } + + /** + * Sets the signing key id when using an assertion profile. + * + * @param string $signingKeyId + * @return void + */ + public function setSigningKeyId($signingKeyId) + { + $this->signingKeyId = $signingKeyId; + } + + /** + * Gets the signing algorithm when using an assertion profile. + * + * @return ?string + */ + public function getSigningAlgorithm() + { + return $this->signingAlgorithm; + } + + /** + * Sets the signing algorithm when using an assertion profile. + * + * @param ?string $signingAlgorithm + * @return void + */ + public function setSigningAlgorithm($signingAlgorithm) + { + if (is_null($signingAlgorithm)) { + $this->signingAlgorithm = null; + } elseif (!in_array($signingAlgorithm, self::$knownSigningAlgorithms)) { + throw new InvalidArgumentException('unknown signing algorithm'); + } else { + $this->signingAlgorithm = $signingAlgorithm; + } + } + + /** + * Gets the set of parameters used by extension when using an extension + * grant type. + * + * @return array + */ + public function getExtensionParams() + { + return $this->extensionParams; + } + + /** + * Sets the set of parameters used by extension when using an extension + * grant type. + * + * @param array $extensionParams + * @return void + */ + public function setExtensionParams($extensionParams) + { + $this->extensionParams = $extensionParams; + } + + /** + * Gets the number of seconds assertions are valid for. + * + * @return int + */ + public function getExpiry() + { + return $this->expiry; + } + + /** + * Sets the number of seconds assertions are valid for. + * + * @param int $expiry + * @return void + */ + public function setExpiry($expiry) + { + $this->expiry = $expiry; + } + + /** + * Gets the lifetime of the access token in seconds. + * + * @return int + */ + public function getExpiresIn() + { + return $this->expiresIn; + } + + /** + * Sets the lifetime of the access token in seconds. + * + * @param ?int $expiresIn + * @return void + */ + public function setExpiresIn($expiresIn) + { + if (is_null($expiresIn)) { + $this->expiresIn = null; + $this->issuedAt = null; + } else { + $this->issuedAt = time(); + $this->expiresIn = (int) $expiresIn; + } + } + + /** + * Gets the time the current access token expires at. + * + * @return ?int + */ + public function getExpiresAt() + { + if (!is_null($this->expiresAt)) { + return $this->expiresAt; + } + + if (!is_null($this->issuedAt) && !is_null($this->expiresIn)) { + return $this->issuedAt + $this->expiresIn; + } + + return null; + } + + /** + * Returns true if the acccess token has expired. + * + * @return bool + */ + public function isExpired() + { + $expiration = $this->getExpiresAt(); + $now = time(); + + return !is_null($expiration) && $now >= $expiration; + } + + /** + * Sets the time the current access token expires at. + * + * @param int $expiresAt + * @return void + */ + public function setExpiresAt($expiresAt) + { + $this->expiresAt = $expiresAt; + } + + /** + * Gets the time the current access token was issued at. + * + * @return ?int + */ + public function getIssuedAt() + { + return $this->issuedAt; + } + + /** + * Sets the time the current access token was issued at. + * + * @param int $issuedAt + * @return void + */ + public function setIssuedAt($issuedAt) + { + $this->issuedAt = $issuedAt; + } + + /** + * Gets the current access token. + * + * @return ?string + */ + public function getAccessToken() + { + return $this->accessToken; + } + + /** + * Sets the current access token. + * + * @param string $accessToken + * @return void + */ + public function setAccessToken($accessToken) + { + $this->accessToken = $accessToken; + } + + /** + * Gets the current ID token. + * + * @return ?string + */ + public function getIdToken() + { + return $this->idToken; + } + + /** + * Sets the current ID token. + * + * @param string $idToken + * @return void + */ + public function setIdToken($idToken) + { + $this->idToken = $idToken; + } + + /** + * Get the granted space-separated scopes (if they exist) for the last + * fetched token. + * + * @return string|null + */ + public function getGrantedScope() + { + return $this->grantedScope; + } + + /** + * Sets the current ID token. + * + * @param string $grantedScope + * @return void + */ + public function setGrantedScope($grantedScope) + { + $this->grantedScope = $grantedScope; + } + + /** + * Gets the refresh token associated with the current access token. + * + * @return ?string + */ + public function getRefreshToken() + { + return $this->refreshToken; + } + + /** + * Sets the refresh token associated with the current access token. + * + * @param string $refreshToken + * @return void + */ + public function setRefreshToken($refreshToken) + { + $this->refreshToken = $refreshToken; + } + + /** + * Sets additional claims to be included in the JWT token + * + * @param array $additionalClaims + * @return void + */ + public function setAdditionalClaims(array $additionalClaims) + { + $this->additionalClaims = $additionalClaims; + } + + /** + * Gets the additional claims to be included in the JWT token. + * + * @return array + */ + public function getAdditionalClaims() + { + return $this->additionalClaims; + } + + /** + * Gets the additional claims to be included in the JWT token. + * + * @return ?string + */ + public function getIssuedTokenType() + { + return $this->issuedTokenType; + } + + /** + * The expiration of the last received token. + * + * @return array|null + */ + public function getLastReceivedToken() + { + if ($token = $this->getAccessToken()) { + // the bare necessity of an auth token + $authToken = [ + 'access_token' => $token, + 'expires_at' => $this->getExpiresAt(), + ]; + } elseif ($idToken = $this->getIdToken()) { + $authToken = [ + 'id_token' => $idToken, + 'expires_at' => $this->getExpiresAt(), + ]; + } else { + return null; + } + + if ($expiresIn = $this->getExpiresIn()) { + $authToken['expires_in'] = $expiresIn; + } + if ($issuedAt = $this->getIssuedAt()) { + $authToken['issued_at'] = $issuedAt; + } + if ($refreshToken = $this->getRefreshToken()) { + $authToken['refresh_token'] = $refreshToken; + } + + return $authToken; + } + + /** + * Get the client ID. + * + * Alias of {@see OAuth2::getClientId()}. + * + * @param callable|null $httpHandler + * @return string + * @access private + */ + public function getClientName(?callable $httpHandler = null) + { + return $this->getClientId(); + } + + /** + * @todo handle uri as array + * + * @param ?string $uri + * @return null|UriInterface + */ + private function coerceUri($uri) + { + if (is_null($uri)) { + return null; + } + + return Utils::uriFor($uri); + } + + /** + * @param string $idToken + * @param Key|Key[]|string|string[] $publicKey + * @param string|string[] $allowedAlgs + * @return object + */ + private function jwtDecode($idToken, $publicKey, $allowedAlgs) + { + $keys = $this->getFirebaseJwtKeys($publicKey, $allowedAlgs); + + // Default exception if none are caught. We are using the same exception + // class and message from firebase/php-jwt to preserve backwards + // compatibility. + $e = new \InvalidArgumentException('Key may not be empty'); + foreach ($keys as $key) { + try { + return JWT::decode($idToken, $key); + } catch (\Exception $e) { + // try next alg + } + } + throw $e; + } + + /** + * @param Key|Key[]|string|string[] $publicKey + * @param string|string[] $allowedAlgs + * @return Key[] + */ + private function getFirebaseJwtKeys($publicKey, $allowedAlgs) + { + // If $publicKey is instance of Key, return it + if ($publicKey instanceof Key) { + return [$publicKey]; + } + + // If $allowedAlgs is empty, $publicKey must be Key or Key[]. + if (empty($allowedAlgs)) { + $keys = []; + foreach ((array) $publicKey as $kid => $pubKey) { + if (!$pubKey instanceof Key) { + throw new \InvalidArgumentException(sprintf( + 'When allowed algorithms is empty, the public key must' + . 'be an instance of %s or an array of %s objects', + Key::class, + Key::class + )); + } + $keys[$kid] = $pubKey; + } + return $keys; + } + + $allowedAlg = null; + if (is_string($allowedAlgs)) { + $allowedAlg = $allowedAlgs; + } elseif (is_array($allowedAlgs)) { + if (count($allowedAlgs) > 1) { + throw new \InvalidArgumentException( + 'To have multiple allowed algorithms, You must provide an' + . ' array of Firebase\JWT\Key objects.' + . ' See https://github.com/firebase/php-jwt for more information.' + ); + } + $allowedAlg = array_pop($allowedAlgs); + } else { + throw new \InvalidArgumentException('allowed algorithms must be a string or array.'); + } + + if (is_array($publicKey)) { + // When publicKey is greater than 1, create keys with the single alg. + $keys = []; + foreach ($publicKey as $kid => $pubKey) { + if ($pubKey instanceof Key) { + $keys[$kid] = $pubKey; + } else { + $keys[$kid] = new Key($pubKey, $allowedAlg); + } + } + return $keys; + } + + return [new Key($publicKey, $allowedAlg)]; + } + + /** + * Determines if the URI is absolute based on its scheme and host or path + * (RFC 3986). + * + * @param string $uri + * @return bool + */ + private function isAbsoluteUri($uri) + { + $uri = $this->coerceUri($uri); + + return $uri->getScheme() && ($uri->getHost() || $uri->getPath()); + } + + /** + * @param array $params + * @return array + */ + private function addClientCredentials(&$params) + { + $clientId = $this->getClientId(); + $clientSecret = $this->getClientSecret(); + + if ($clientId && $clientSecret) { + $params['client_id'] = $clientId; + $params['client_secret'] = $clientSecret; + } + + return $params; + } +} diff --git a/Auth/src/ProjectIdProviderInterface.php b/Auth/src/ProjectIdProviderInterface.php new file mode 100644 index 000000000000..8d10c293a18b --- /dev/null +++ b/Auth/src/ProjectIdProviderInterface.php @@ -0,0 +1,32 @@ +auth->getSigningKey(); + + $signedString = ''; + if (class_exists(phpseclib3\Crypt\RSA::class) && !$forceOpenssl) { + $key = PublicKeyLoader::load($privateKey); + $rsa = $key->withHash('sha256')->withPadding(RSA::SIGNATURE_PKCS1); + + $signedString = $rsa->sign($stringToSign); + } elseif (extension_loaded('openssl')) { + openssl_sign($stringToSign, $signedString, $privateKey, 'sha256WithRSAEncryption'); + } else { + // @codeCoverageIgnoreStart + throw new \RuntimeException('OpenSSL is not installed.'); + } + // @codeCoverageIgnoreEnd + + return base64_encode($signedString); + } +} diff --git a/Auth/src/SignBlobInterface.php b/Auth/src/SignBlobInterface.php new file mode 100644 index 000000000000..b3c2b05059ff --- /dev/null +++ b/Auth/src/SignBlobInterface.php @@ -0,0 +1,44 @@ + $metadata metadata hashmap + * @param string $authUri optional auth uri + * @param callable|null $httpHandler callback which delivers psr7 request + * @return array updated metadata hashmap + */ + public function updateMetadata( + $metadata, + $authUri = null, + ?callable $httpHandler = null + ); +} diff --git a/Auth/src/UpdateMetadataTrait.php b/Auth/src/UpdateMetadataTrait.php new file mode 100644 index 000000000000..bacd3ffc74c5 --- /dev/null +++ b/Auth/src/UpdateMetadataTrait.php @@ -0,0 +1,74 @@ + $metadata metadata hashmap + * @param string $authUri optional auth uri + * @param callable|null $httpHandler callback which delivers psr7 request + * @return array updated metadata hashmap + */ + public function updateMetadata( + $metadata, + $authUri = null, + ?callable $httpHandler = null + ) { + $metadata_copy = $metadata; + + // We do need to set the service api usage metrics irrespective even if + // the auth token is set because invoking this method with auth tokens + // would mean the intention is to just explicitly set the metrics metadata. + $metadata_copy = $this->applyServiceApiUsageMetrics($metadata_copy); + + if (isset($metadata_copy[self::AUTH_METADATA_KEY])) { + // Auth metadata has already been set + return $metadata_copy; + } + $result = $this->fetchAuthToken($httpHandler); + if (isset($result['access_token'])) { + $metadata_copy[self::AUTH_METADATA_KEY] = ['Bearer ' . $result['access_token']]; + } elseif (isset($result['id_token'])) { + $metadata_copy[self::AUTH_METADATA_KEY] = ['Bearer ' . $result['id_token']]; + } + return $metadata_copy; + } +} diff --git a/Auth/tests/AccessTokenTest.php b/Auth/tests/AccessTokenTest.php new file mode 100644 index 000000000000..d18f5c38aad8 --- /dev/null +++ b/Auth/tests/AccessTokenTest.php @@ -0,0 +1,601 @@ +cache = $this->prophesize('Psr\Cache\CacheItemPoolInterface'); + $this->jwt = $this->prophesize('Firebase\JWT\JWT'); + $this->token = 'foobar'; + $this->publicKey = 'barfoo'; + + $this->payload = [ + 'iat' => time(), + 'exp' => time() + 30, + 'name' => 'foo', + 'iss' => AccessToken::OAUTH2_ISSUER_HTTPS + ]; + } + + /** + * @dataProvider verifyCalls + */ + public function testVerify( + $payload, + $expected, + $audience = null, + $exception = null, + $certsLocation = null, + $issuer = null + ) { + $item = $this->prophesize('Psr\Cache\CacheItemInterface'); + $item->get()->willReturn([ + 'keys' => [ + [ + 'kid' => 'ddddffdfd', + 'e' => 'AQAB', + 'kty' => 'RSA', + 'alg' => $certsLocation ? 'ES256' : 'RS256', + 'n' => $this->publicKey, + 'use' => 'sig' + ] + ] + ]); + + $cacheKey = 'google_auth_certs_cache|' . + ($certsLocation ? sha1($certsLocation) : 'federated_signon_certs_v3'); + $this->cache->getItem($cacheKey) + ->shouldBeCalledTimes(1) + ->willReturn($item->reveal()); + + $token = new AccessTokenStub( + null, + $this->cache->reveal() + ); + + $token->mocks['decode'] = function ($token, $keys) use ($payload, $exception) { + $this->assertEquals($this->token, $token); + + if ($exception) { + throw $exception; + } + + return (object) $payload; + }; + + $e = null; + $res = false; + try { + $res = $token->verify($this->token, [ + 'audience' => $audience, + 'issuer' => $issuer, + 'certsLocation' => $certsLocation, + 'throwException' => (bool) $exception, + ]); + } catch (\Exception $e) { + } + + $this->assertEquals($expected, $res); + $this->assertEquals($exception, $e); + } + + public function verifyCalls() + { + $this->setUp(); + + if (class_exists('Firebase\JWT\JWT')) { + $expiredException = 'Firebase\JWT\ExpiredException'; + $sigInvalidException = 'Firebase\JWT\SignatureInvalidException'; + } else { + $expiredException = 'ExpiredException'; + $sigInvalidException = 'SignatureInvalidException'; + } + + return [ + [ + $this->payload, + $this->payload, + ], [ + $this->payload + [ + 'aud' => 'foo' + ], + $this->payload + [ + 'aud' => 'foo' + ], + 'foo' + ], [ + $this->payload + [ + 'aud' => 'foo' + ], + false, + 'bar' + ], [ + [ + 'iss' => 'invalid' + ] + $this->payload, + false + ], [ + [ + 'iss' => 'baz' + ] + $this->payload, + [ + 'iss' => 'baz' + ] + $this->payload, + null, + null, + null, + 'baz' + ], [ + $this->payload, + false, + null, + new $expiredException('expired!') + ], [ + $this->payload, + false, + null, + new $sigInvalidException('invalid!') + ], [ + $this->payload, + false, + null, + new \DomainException('expired!') + ], [ + [ + 'iss' => AccessToken::IAP_ISSUER + ] + $this->payload, [ + 'iss' => AccessToken::IAP_ISSUER + ] + $this->payload, + null, + null, + AccessToken::IAP_CERT_URL + ], [ + [ + 'iss' => 'invalid', + ] + $this->payload, + false, + null, + null, + AccessToken::IAP_CERT_URL + ], [ + [ + 'iss' => AccessToken::IAP_ISSUER, + ] + $this->payload + [ + 'aud' => 'foo' + ], + false, + 'bar', + null, + AccessToken::IAP_CERT_URL + ], [ + [ + 'iss' => 'baz' + ] + $this->payload, + false, + null, + null, + AccessToken::IAP_CERT_URL + ], [ + [ + 'iss' => 'baz' + ] + $this->payload, [ + 'iss' => 'baz' + ] + $this->payload, + null, + null, + AccessToken::IAP_CERT_URL, + 'baz' + ], [ + $this->payload, + null, + 'foo', + new UnexpectedValueException('Audience does not match'), + ] + ]; + } + + public function testEsVerifyEndToEnd() + { + if (!$jwt = getenv('IAP_IDENTITY_TOKEN')) { + $this->markTestSkipped('Set the IAP_IDENTITY_TOKEN env var'); + } + + $token = new AccessTokenStub(); + $token->mocks['decode'] = function ($token, $publicKey, $allowedAlgs) { + // Skip expired validation + $jwt = SimpleJWT::decode( + $token, + $publicKey, + $allowedAlgs, + null, + ['exp'] + ); + return $jwt->getClaims(); + }; + + // Use Iap Cert URL + $payload = $token->verify($jwt, [ + 'certsLocation' => AccessToken::IAP_CERT_URL, + 'throwException' => true, + 'issuer' => 'https://cloud.google.com/iap', + ]); + + $this->assertNotFalse($payload); + $this->assertArrayHasKey('iss', $payload); + $this->assertEquals('https://cloud.google.com/iap', $payload['iss']); + } + + /** + * @dataProvider provideCertsFromUrl + */ + public function testGetCertsFromUrl($certUrl) + { + $token = new AccessToken(); + $reflector = new \ReflectionObject($token); + $cacheKeyMethod = $reflector->getMethod('getCacheKeyFromCertLocation'); + $getCertsMethod = $reflector->getMethod('getCerts'); + $cacheKey = $cacheKeyMethod->invoke($token, $certUrl); + $certs = $getCertsMethod->invoke( + $token, + $certUrl, + $cacheKey + ); + $this->assertTrue(is_array($certs)); + $this->assertGreaterThanOrEqual(2, count($certs)); + } + + public function provideCertsFromUrl() + { + return [ + [AccessToken::IAP_CERT_URL], + [AccessToken::FEDERATED_SIGNON_CERT_URL], + ]; + } + + public function testRetrieveCertsFromLocationLocalFile() + { + $certsLocation = __DIR__ . '/fixtures/fixtures1/federated-certs.json'; + $certsData = json_decode(file_get_contents($certsLocation), true); + + $item = $this->prophesize('Psr\Cache\CacheItemInterface'); + $item->get() + ->shouldBeCalledTimes(1) + ->willReturn(null); + $item->set($certsData) + ->shouldBeCalledTimes(1) + ->willReturn($item->reveal()); + $item->expiresAt(Argument::type('\DateTime')) + ->shouldBeCalledTimes(1) + ->willReturn($item->reveal()); + + $this->cache->getItem('google_auth_certs_cache|' . sha1($certsLocation)) + ->shouldBeCalledTimes(1) + ->willReturn($item->reveal()); + + $this->cache->save(Argument::type('Psr\Cache\CacheItemInterface')) + ->shouldBeCalledTimes(1); + + $token = new AccessTokenStub( + null, + $this->cache->reveal() + ); + + $token->mocks['decode'] = function ($token, $keys) { + $this->assertEquals($this->token, $token); + $this->assertEquals('RS256', array_pop($keys)->getAlgorithm()); + + return (object) $this->payload; + }; + + $token->verify($this->token, [ + 'certsLocation' => $certsLocation + ]); + } + + public function testRetrieveCertsFromLocationLocalFileInvalidFilePath() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Failed to retrieve verification certificates from path'); + + $certsLocation = __DIR__ . '/fixtures/fixtures1/federated-certs-does-not-exist.json'; + + $item = $this->prophesize('Psr\Cache\CacheItemInterface'); + $item->get() + ->shouldBeCalledTimes(1) + ->willReturn(null); + + $this->cache->getItem('google_auth_certs_cache|' . sha1($certsLocation)) + ->shouldBeCalledTimes(1) + ->willReturn($item->reveal()); + + $token = new AccessTokenStub( + null, + $this->cache->reveal() + ); + + $token->verify($this->token, [ + 'certsLocation' => $certsLocation + ]); + } + + public function testRetrieveCertsInvalidData() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('federated sign-on certs expects "keys" to be set'); + + $item = $this->prophesize('Psr\Cache\CacheItemInterface'); + $item->get() + ->shouldBeCalledTimes(1) + ->willReturn('{}'); + + $this->cache->getItem('google_auth_certs_cache|federated_signon_certs_v3') + ->shouldBeCalledTimes(1) + ->willReturn($item->reveal()); + + $token = new AccessTokenStub( + null, + $this->cache->reveal() + ); + + $token->verify($this->token); + } + + public function testRetrieveCertsFromLocationLocalFileInvalidFileData() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('federated sign-on certs expects "keys" to be set'); + + $temp = tmpfile(); + fwrite($temp, '{}'); + $certsLocation = stream_get_meta_data($temp)['uri']; + + $item = $this->prophesize('Psr\Cache\CacheItemInterface'); + $item->get() + ->shouldBeCalledTimes(1) + ->willReturn(null); + + $this->cache->getItem('google_auth_certs_cache|' . sha1($certsLocation)) + ->shouldBeCalledTimes(1) + ->willReturn($item->reveal()); + + $token = new AccessTokenStub( + null, + $this->cache->reveal() + ); + + $token->verify($this->token, [ + 'certsLocation' => $certsLocation + ]); + } + + public function testRetrieveCertsFromLocationRespectsCacheControl() + { + $certsLocation = __DIR__ . '/fixtures/fixtures1/federated-certs.json'; + $certsJson = file_get_contents($certsLocation); + $certsData = json_decode($certsJson, true); + + $httpHandler = function (RequestInterface $request) use ($certsJson) { + return new Response(200, [ + 'cache-control' => 'public, max-age=1000', + ], $certsJson); + }; + + $phpunit = $this; + + $item = $this->prophesize('Psr\Cache\CacheItemInterface'); + $item->get() + ->shouldBeCalledTimes(1) + ->willReturn(null); + $item->set($certsData) + ->shouldBeCalledTimes(1) + ->willReturn($item->reveal()); + + // Assert date-time is set with difference of 1000 (the max-age in the Cache-Control header) + $item->expiresAt(Argument::type('\DateTime')) + ->shouldBeCalledTimes(1) + ->will(function ($value) use ($phpunit) { + $phpunit->assertEqualsWithDelta(1000, $value[0]->getTimestamp() - time(), 1); + return $this; + }); + + $this->cache->getItem('google_auth_certs_cache|federated_signon_certs_v3') + ->shouldBeCalledTimes(1) + ->willReturn($item->reveal()); + + $this->cache->save(Argument::type('Psr\Cache\CacheItemInterface')) + ->shouldBeCalledTimes(1); + + $token = new AccessTokenStub( + $httpHandler, + $this->cache->reveal() + ); + + $token->verify($this->token); + } + + public function testRetrieveCertsFromLocationRemote() + { + $certsLocation = __DIR__ . '/fixtures/fixtures1/federated-certs.json'; + $certsJson = file_get_contents($certsLocation); + $certsData = json_decode($certsJson, true); + + $httpHandler = function (RequestInterface $request) use ($certsJson) { + $this->assertEquals(AccessToken::FEDERATED_SIGNON_CERT_URL, (string) $request->getUri()); + $this->assertEquals('GET', $request->getMethod()); + + return new Response(200, [], $certsJson); + }; + + $item = $this->prophesize('Psr\Cache\CacheItemInterface'); + $item->get() + ->shouldBeCalledTimes(1) + ->willReturn(null); + $item->set($certsData) + ->shouldBeCalledTimes(1) + ->willReturn($item->reveal()); + $item->expiresAt(Argument::type('\DateTime')) + ->shouldBeCalledTimes(1) + ->willReturn($item->reveal()); + + $this->cache->getItem('google_auth_certs_cache|federated_signon_certs_v3') + ->shouldBeCalledTimes(1) + ->willReturn($item->reveal()); + + $this->cache->save(Argument::type('Psr\Cache\CacheItemInterface')) + ->shouldBeCalledTimes(1); + + $token = new AccessTokenStub( + $httpHandler, + $this->cache->reveal() + ); + + $token->mocks['decode'] = function ($token, $keys) { + $this->assertEquals($this->token, $token); + $this->assertEquals('RS256', array_pop($keys)->getAlgorithm()); + + return (object) $this->payload; + }; + + $token->verify($this->token); + } + + public function testRetrieveCertsFromLocationRemoteBadRequest() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('bad news guys'); + + $badBody = 'bad news guys'; + + $httpHandler = function (RequestInterface $request) use ($badBody) { + return new Response(500, [], $badBody); + }; + + $item = $this->prophesize('Psr\Cache\CacheItemInterface'); + $item->get() + ->shouldBeCalledTimes(1) + ->willReturn(null); + + $this->cache->getItem('google_auth_certs_cache|federated_signon_certs_v3') + ->shouldBeCalledTimes(1) + ->willReturn($item->reveal()); + + $token = new AccessTokenStub( + $httpHandler, + $this->cache->reveal() + ); + + $token->verify($this->token); + } + + /** + * @dataProvider revokeTokens + */ + public function testRevoke($input, $expected) + { + $httpHandler = function (RequestInterface $request) use ($expected) { + $this->assertEquals('no-store', $request->getHeaderLine('Cache-Control')); + $this->assertEquals('application/x-www-form-urlencoded', $request->getHeaderLine('Content-Type')); + $this->assertEquals('POST', $request->getMethod()); + $this->assertEquals(AccessToken::OAUTH2_REVOKE_URI, (string) $request->getUri()); + $this->assertEquals('token=' . $expected, (string) $request->getBody()); + + return new Response(200); + }; + + $token = new AccessToken($httpHandler); + + $this->assertTrue($token->revoke($input)); + } + + public function revokeTokens() + { + $this->setUp(); + + return [ + [ + $this->token, + $this->token + ], [ + ['refresh_token' => $this->token, 'access_token' => 'other thing'], + $this->token + ], [ + ['access_token' => $this->token], + $this->token + ] + ]; + } + + public function testRevokeFails() + { + $httpHandler = function (RequestInterface $request) { + return new Response(500); + }; + + $token = new AccessToken($httpHandler); + + $this->assertFalse($token->revoke($this->token)); + } +} + +//@codingStandardsIgnoreStart +class AccessTokenStub extends AccessToken +{ + public $mocks = []; + + protected function callJwtStatic($method, array $args = []) + { + return isset($this->mocks[$method]) + ? call_user_func_array($this->mocks[$method], $args) + : parent::callJwtStatic($method, $args); + } + + protected function callSimpleJwtDecode(array $args = []) + { + if (isset($this->mocks['decode'])) { + $claims = call_user_func_array($this->mocks['decode'], $args); + return new SimpleJWT([], (array) $claims); + } + + return parent::callSimpleJwtDecode($args); + } +} +//@codingStandardsIgnoreEnd diff --git a/Auth/tests/ApplicationDefaultCredentialsTest.php b/Auth/tests/ApplicationDefaultCredentialsTest.php new file mode 100644 index 000000000000..aa3c1170c10b --- /dev/null +++ b/Auth/tests/ApplicationDefaultCredentialsTest.php @@ -0,0 +1,951 @@ +expectException(DomainException::class); + + $keyFile = __DIR__ . '/fixtures/fixtures1/does-not-exist-private.json'; + putenv(ServiceAccountCredentials::ENV_VAR . '=' . $keyFile); + ApplicationDefaultCredentials::getCredentials('a scope'); + } + + public function testLoadsOKIfEnvSpecifiedIsValid() + { + $keyFile = __DIR__ . '/fixtures/fixtures1/private.json'; + putenv(ServiceAccountCredentials::ENV_VAR . '=' . $keyFile); + $this->assertNotNull( + ApplicationDefaultCredentials::getCredentials('a scope') + ); + } + + public function testLoadsDefaultFileIfPresentAndEnvVarIsNotSet() + { + $this->setHomeEnv(__DIR__ . '/fixtures/fixtures1'); + $this->assertNotNull( + ApplicationDefaultCredentials::getCredentials('a scope') + ); + } + + public function testFailsIfNotOnGceAndNoDefaultFileFound() + { + $this->expectException(DomainException::class); + $this->skipResidencyCheck(); + $this->setHomeEnv(__DIR__ . '/not_exist_fixtures'); + + // simulate not being GCE and retry attempts by returning multiple 500s + $httpHandler = $this->getHandler([ + new Response(500), + new Response(500), + new Response(500) + ]); + + ApplicationDefaultCredentials::getCredentials('a scope', $httpHandler); + } + + public function testSuccedsIfNoDefaultFilesButIsOnGCE() + { + $this->setHomeEnv(null); + + $wantedTokens = [ + 'access_token' => '1/abdef1234567890', + 'expires_in' => '57', + 'token_type' => 'Bearer', + ]; + $jsonTokens = json_encode($wantedTokens); + + // simulate the response from GCE. + $httpHandler = $this->getHandler([ + new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']), + new Response(200, [], Utils::streamFor($jsonTokens)), + ]); + + $this->assertInstanceOf( + GCECredentials::class, + ApplicationDefaultCredentials::getCredentials('a scope', $httpHandler) + ); + } + + public function testGceCredentials() + { + $this->setHomeEnv(null); + + $jsonTokens = json_encode(['access_token' => 'abc']); + + $creds = ApplicationDefaultCredentials::getCredentials( + null, // $scope + $httpHandler = $this->getHandler([ + new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']), + new Response(200, [], Utils::streamFor($jsonTokens)), + ]), // $httpHandler + null, // $cacheConfig + null, // $cache + null, // $quotaProject + 'a+default+scope' // $defaultScope + ); + + $this->assertInstanceOf(GCECredentials::class, $creds); + + $uriProperty = (new ReflectionClass($creds))->getProperty('tokenUri'); + + // used default scope + $tokenUri = $uriProperty->getValue($creds); + $this->assertStringContainsString('a+default+scope', $tokenUri); + + $creds = ApplicationDefaultCredentials::getCredentials( + 'a+user+scope', // $scope + $this->getHandler([ + new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']), + new Response(200, [], Utils::streamFor($jsonTokens)), + ]), // $httpHandler + null, // $cacheConfig + null, // $cache + null, // $quotaProject + 'a+default+scope' // $defaultScope + ); + + // did not use default scope + $tokenUri = $uriProperty->getValue($creds); + $this->assertStringContainsString('a+user+scope', $tokenUri); + } + + public function testImpersonatedServiceAccountCredentials() + { + $this->setHomeEnv(__DIR__ . '/fixtures/fixtures5'); + $creds = ApplicationDefaultCredentials::getCredentials( + null, + null, + null, + null, + null, + 'a default scope' + ); + + $this->assertInstanceOf(ImpersonatedServiceAccountCredentials::class, $creds); + $this->assertEquals('service_account_name@namespace.iam.gserviceaccount.com', $creds->getClientName()); + + $sourceCredentialsProperty = (new ReflectionClass($creds))->getProperty('sourceCredentials'); + + // used default scope + $sourceCredentials = $sourceCredentialsProperty->getValue($creds); + $this->assertInstanceOf(UserRefreshCredentials::class, $sourceCredentials); + } + + public function testUserRefreshCredentials() + { + $this->setHomeEnv(__DIR__ . '/fixtures/fixtures2'); + + $creds = ApplicationDefaultCredentials::getCredentials( + null, // $scope + null, // $httpHandler + null, // $cacheConfig + null, // $cache + null, // $quotaProject + 'a default scope' // $defaultScope + ); + + $this->assertInstanceOf(UserRefreshCredentials::class, $creds); + + $authProperty = (new ReflectionClass($creds))->getProperty('auth'); + + // used default scope + $auth = $authProperty->getValue($creds); + $this->assertEquals('a default scope', $auth->getScope()); + + $creds = ApplicationDefaultCredentials::getCredentials( + 'a user scope', // $scope + null, // $httpHandler + null, // $cacheConfig + null, // $cache + null, // $quotaProject + 'a default scope' // $defaultScope + ); + + // did not use default scope + $auth = $authProperty->getValue($creds); + $this->assertEquals('a user scope', $auth->getScope()); + } + + public function testServiceAccountCredentials() + { + $this->setHomeEnv(__DIR__ . '/fixtures/fixtures1'); + + $creds = ApplicationDefaultCredentials::getCredentials( + null, // $scope + null, // $httpHandler + null, // $cacheConfig + null, // $cache + null, // $quotaProject + 'a default scope' // $defaultScope + ); + + $this->assertInstanceOf(ServiceAccountCredentials::class, $creds); + + $authProperty = (new ReflectionClass($creds))->getProperty('auth'); + + // did not use default scope + $auth = $authProperty->getValue($creds); + $this->assertEquals('', $auth->getScope()); + + $creds = ApplicationDefaultCredentials::getCredentials( + 'a user scope', // $scope + null, // $httpHandler + null, // $cacheConfig + null, // $cache + null, // $quotaProject + 'a default scope' // $defaultScope + ); + + // used user scope + $auth = $authProperty->getValue($creds); + $this->assertEquals('a user scope', $auth->getScope()); + } + + public function testDefaultScopeArray() + { + $this->setHomeEnv(__DIR__ . '/fixtures/fixtures2'); + + $creds = ApplicationDefaultCredentials::getCredentials( + null, // $scope + null, // $httpHandler + null, // $cacheConfig + null, // $cache + null, // $quotaProject + ['onescope', 'twoscope'] // $defaultScope + ); + + $authProperty = (new ReflectionClass($creds))->getProperty('auth'); + + // used default scope + $auth = $authProperty->getValue($creds); + $this->assertEquals('onescope twoscope', $auth->getScope()); + } + + public function testGetMiddlewareFailsIfEnvSpecifiesNonExistentFile() + { + $this->expectException(DomainException::class); + + $keyFile = __DIR__ . '/fixtures/fixtures1/does-not-exist-private.json'; + putenv(ServiceAccountCredentials::ENV_VAR . '=' . $keyFile); + ApplicationDefaultCredentials::getMiddleware('a scope'); + } + + public function testGetMiddlewareLoadsOKIfEnvSpecifiedIsValid() + { + $keyFile = __DIR__ . '/fixtures/fixtures1/private.json'; + putenv(ServiceAccountCredentials::ENV_VAR . '=' . $keyFile); + $this->assertNotNull(ApplicationDefaultCredentials::getMiddleware('a scope')); + } + + public function testLGetMiddlewareoadsDefaultFileIfPresentAndEnvVarIsNotSet() + { + $this->setHomeEnv(__DIR__ . '/fixtures/fixtures1'); + $this->assertNotNull(ApplicationDefaultCredentials::getMiddleware('a scope')); + } + + public function testGetMiddlewareFailsIfNotOnGceAndNoDefaultFileFound() + { + $this->expectException(DomainException::class); + + $this->skipResidencyCheck(); + $this->setHomeEnv(__DIR__ . '/not_exist_fixtures'); + + // simulate not being GCE and retry attempts by returning multiple 500s + $httpHandler = $this->getHandler([ + new Response(500), + new Response(500), + new Response(500) + ]); + + ApplicationDefaultCredentials::getMiddleware('a scope', $httpHandler); + } + + public function testGetMiddlewareWithCacheOptions() + { + $keyFile = __DIR__ . '/fixtures/fixtures1/private.json'; + putenv(ServiceAccountCredentials::ENV_VAR . '=' . $keyFile); + + $httpHandler = $this->getHandler([ + new Response(200), + ]); + + $cacheOptions = []; + $cachePool = $this->prophesize(CacheItemPoolInterface::class); + + $middleware = ApplicationDefaultCredentials::getMiddleware( + 'a scope', + $httpHandler, + $cacheOptions, + $cachePool->reveal() + ); + + $this->assertNotNull($middleware); + } + + public function testGetMiddlewareSuccedsIfNoDefaultFilesButIsOnGCE() + { + $wantedTokens = [ + 'access_token' => '1/abdef1234567890', + 'expires_in' => '57', + 'token_type' => 'Bearer', + ]; + $jsonTokens = json_encode($wantedTokens); + + // simulate the response from GCE. + $httpHandler = $this->getHandler([ + new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']), + new Response(200, [], Utils::streamFor($jsonTokens)), + ]); + + $this->assertNotNull(ApplicationDefaultCredentials::getMiddleware('a scope', $httpHandler)); + } + + public function testOnGceCacheWithHit() + { + $this->expectException(DomainException::class); + + $this->setHomeEnv(__DIR__ . '/not_exist_fixtures'); + + $mockCacheItem = $this->prophesize('Psr\Cache\CacheItemInterface'); + $mockCacheItem->isHit() + ->willReturn(true); + $mockCacheItem->get() + ->shouldBeCalledTimes(1) + ->willReturn(false); + + $mockCache = $this->prophesize(CacheItemPoolInterface::class); + $mockCache->getItem(GCECache::GCE_CACHE_KEY) + ->shouldBeCalledTimes(1) + ->willReturn($mockCacheItem->reveal()); + + ApplicationDefaultCredentials::getMiddleware( + 'a scope', + null, + null, + $mockCache->reveal() + ); + } + + public function testOnGceCacheWithoutHit() + { + $this->setHomeEnv(__DIR__ . '/not_exist_fixtures'); + + $gceIsCalled = false; + $dummyHandler = function ($request) use (&$gceIsCalled) { + $gceIsCalled = true; + return new Psr7\Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']); + }; + $mockCacheItem = $this->prophesize('Psr\Cache\CacheItemInterface'); + $mockCacheItem->isHit() + ->willReturn(false); + $mockCacheItem->set(true) + ->shouldBeCalledTimes(1) + ->willReturn($mockCacheItem->reveal()); + $mockCacheItem->expiresAfter(1500) + ->shouldBeCalledTimes(1) + ->willReturn($mockCacheItem->reveal()); + + $mockCache = $this->prophesize(CacheItemPoolInterface::class); + $mockCache->getItem(GCECache::GCE_CACHE_KEY) + ->shouldBeCalledTimes(2) + ->willReturn($mockCacheItem->reveal()); + $mockCache->save($mockCacheItem->reveal()) + ->shouldBeCalled(); + + $creds = ApplicationDefaultCredentials::getMiddleware( + 'a scope', + $dummyHandler, + null, + $mockCache->reveal() + ); + + $this->assertTrue($gceIsCalled); + } + + public function testOnGceCacheWithOptions() + { + $this->setHomeEnv(__DIR__ . '/not_exist_fixtures'); + + $prefix = 'test_prefix_'; + $lifetime = '70707'; + + $gceIsCalled = false; + $dummyHandler = function ($request) use (&$gceIsCalled) { + $gceIsCalled = true; + return new Psr7\Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']); + }; + $mockCacheItem = $this->prophesize('Psr\Cache\CacheItemInterface'); + $mockCacheItem->isHit() + ->willReturn(false); + $mockCacheItem->set(true) + ->shouldBeCalledTimes(1) + ->willReturn($mockCacheItem->reveal()); + $mockCacheItem->expiresAfter($lifetime) + ->shouldBeCalledTimes(1) + ->willReturn($mockCacheItem->reveal()); + + $mockCache = $this->prophesize(CacheItemPoolInterface::class); + $mockCache->getItem($prefix . GCECache::GCE_CACHE_KEY) + ->shouldBeCalledTimes(2) + ->willReturn($mockCacheItem->reveal()); + $mockCache->save($mockCacheItem->reveal()) + ->shouldBeCalled(); + + $creds = ApplicationDefaultCredentials::getMiddleware( + 'a scope', + $dummyHandler, + ['gce_prefix' => $prefix, 'gce_lifetime' => $lifetime], + $mockCache->reveal() + ); + + $this->assertTrue($gceIsCalled); + } + + public function testGetIdTokenCredentialsFailsIfEnvSpecifiesNonExistentFile() + { + $this->expectException(DomainException::class); + + $keyFile = __DIR__ . '/fixtures/fixtures1/does-not-exist-private.json'; + putenv(ServiceAccountCredentials::ENV_VAR . '=' . $keyFile); + ApplicationDefaultCredentials::getIdTokenCredentials($this->targetAudience); + } + + public function testGetIdTokenCredentialsLoadsOKIfEnvSpecifiedIsValid() + { + $keyFile = __DIR__ . '/fixtures/fixtures1/private.json'; + putenv(ServiceAccountCredentials::ENV_VAR . '=' . $keyFile); + + $creds = ApplicationDefaultCredentials::getIdTokenCredentials($this->targetAudience); + $this->assertInstanceOf(ServiceAccountCredentials::class, $creds); + } + + public function testGetIdTokenCredentialsLoadsDefaultFileIfPresentAndEnvVarIsNotSet() + { + $this->setHomeEnv(__DIR__ . '/fixtures/fixtures1'); + $creds = ApplicationDefaultCredentials::getIdTokenCredentials($this->targetAudience); + $this->assertInstanceOf(ServiceAccountCredentials::class, $creds); + } + + public function testGetIdTokenCredentialsFailsIfNotOnGceAndNoDefaultFileFound() + { + $this->expectException(DomainException::class); + $this->expectExceptionMessage('Your default credentials were not found'); + + $this->skipResidencyCheck(); + $this->setHomeEnv(__DIR__ . '/not_exist_fixtures'); + + // simulate not being GCE and retry attempts by returning multiple 500s + $httpHandler = $this->getHandler([ + new Response(500), + new Response(500), + new Response(500) + ]); + + ApplicationDefaultCredentials::getIdTokenCredentials( + $this->targetAudience, + $httpHandler + ); + } + + public function testGetIdTokenCredentialsWithImpersonatedServiceAccountCredentials() + { + $this->setHomeEnv(__DIR__ . '/fixtures/fixtures5'); + $creds = ApplicationDefaultCredentials::getIdTokenCredentials('123@456.com'); + $this->assertInstanceOf(ImpersonatedServiceAccountCredentials::class, $creds); + } + + public function testGetIdTokenCredentialsWithCacheOptions() + { + $keyFile = __DIR__ . '/fixtures/fixtures1/private.json'; + putenv(ServiceAccountCredentials::ENV_VAR . '=' . $keyFile); + + $httpHandler = $this->getHandler([ + new Response(200), + ]); + + $cacheOptions = []; + $cachePool = $this->prophesize(CacheItemPoolInterface::class); + + $credentials = ApplicationDefaultCredentials::getIdTokenCredentials( + $this->targetAudience, + $httpHandler, + $cacheOptions, + $cachePool->reveal() + ); + + $this->assertInstanceOf(FetchAuthTokenCache::class, $credentials); + } + + public function testGetIdTokenCredentialsSuccedsIfNoDefaultFilesButIsOnGCE() + { + $this->setHomeEnv(__DIR__ . '/not_exist_fixtures'); + $wantedTokens = [ + 'access_token' => '1/abdef1234567890', + 'expires_in' => '57', + 'token_type' => 'Bearer', + ]; + $jsonTokens = json_encode($wantedTokens); + + // simulate the response from GCE. + $httpHandler = $this->getHandler([ + new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']), + new Response(200, [], Utils::streamFor($jsonTokens)), + ]); + + $credentials = ApplicationDefaultCredentials::getIdTokenCredentials( + $this->targetAudience, + $httpHandler + ); + + $this->assertInstanceOf(GCECredentials::class, $credentials); + } + + public function testGetIdTokenCredentialsWithUserRefreshCredentials() + { + $this->setHomeEnv(__DIR__ . '/fixtures/fixtures2'); + + $creds = ApplicationDefaultCredentials::getIdTokenCredentials( + $this->targetAudience, + ); + + $this->assertInstanceOf(UserRefreshCredentials::class, $creds); + + $authProperty = (new ReflectionClass($creds))->getProperty('auth'); + + // used default scope + $auth = $authProperty->getValue($creds); + $additionalClaims = $auth->getAdditionalClaims(); + $this->assertArrayHasKey('target_audience', $additionalClaims); + $this->assertEquals($this->targetAudience, $additionalClaims['target_audience']); + } + + public function testWithServiceAccountCredentialsAndExplicitQuotaProject() + { + $keyFile = __DIR__ . '/fixtures/fixtures1/private.json'; + putenv(ServiceAccountCredentials::ENV_VAR . '=' . $keyFile); + + $credentials = ApplicationDefaultCredentials::getCredentials( + null, + null, + null, + null, + $this->quotaProject + ); + + $this->assertInstanceOf(ServiceAccountCredentials::class, $credentials); + + $this->assertEquals( + $this->quotaProject, + $credentials->getQuotaProject() + ); + } + + public function testGetCredentialsUtilizesQuotaProjectInKeyFile() + { + $keyFile = __DIR__ . '/fixtures/fixtures1/private.json'; + putenv(ServiceAccountCredentials::ENV_VAR . '=' . $keyFile); + + $credentials = ApplicationDefaultCredentials::getCredentials(); + + $this->assertEquals( + 'test_quota_project', + $credentials->getQuotaProject() + ); + } + + /** @runInSeparateProcess */ + public function testGetCredentialsUtilizesQuotaProjectEnvVar() + { + $quotaProject = 'quota-project-from-env-var'; + putenv(CredentialsLoader::QUOTA_PROJECT_ENV_VAR . '=' . $quotaProject); + $this->setHomeEnv(__DIR__ . '/fixtures/fixtures1'); + + $credentials = ApplicationDefaultCredentials::getCredentials(); + + $this->assertEquals( + $quotaProject, + $credentials->getQuotaProject() + ); + } + + /** @runInSeparateProcess */ + public function testGetCredentialsUtilizesQuotaProjectParameterOverEnvVar() + { + $quotaProject = 'quota-project-from-parameter'; + putenv(CredentialsLoader::QUOTA_PROJECT_ENV_VAR . '=quota-project-from-env-var'); + $this->setHomeEnv(__DIR__ . '/fixtures/fixtures1'); + + $credentials = ApplicationDefaultCredentials::getCredentials( + null, // $scope + null, // $httpHandler + null, // $cacheConfig + null, // $cache + $quotaProject, // $quotaProject + null // $defaultScope + ); + + $this->assertEquals( + $quotaProject, + $credentials->getQuotaProject() + ); + } + + /** @runInSeparateProcess */ + public function testGetCredentialsUtilizesQuotaProjectEnvVarOverKeyFile() + { + $quotaProject = 'quota-project-from-env-var'; + $keyFile = __DIR__ . '/fixtures/fixtures1/private.json'; + putenv(CredentialsLoader::QUOTA_PROJECT_ENV_VAR . '=' . $quotaProject); + putenv(CredentialsLoader::ENV_VAR . '=' . $keyFile); + + $credentials = ApplicationDefaultCredentials::getCredentials(); + + $this->assertEquals( + $quotaProject, + $credentials->getQuotaProject() + ); + } + + public function testWithFetchAuthTokenCacheAndExplicitQuotaProject() + { + $keyFile = __DIR__ . '/fixtures/fixtures1/private.json'; + putenv(ServiceAccountCredentials::ENV_VAR . '=' . $keyFile); + + $httpHandler = $this->getHandler([ + new Response(200), + ]); + + $cacheOptions = []; + $cachePool = $this->prophesize(CacheItemPoolInterface::class); + + $credentials = ApplicationDefaultCredentials::getCredentials( + null, + $httpHandler, + $cacheOptions, + $cachePool->reveal(), + $this->quotaProject + ); + + $this->assertInstanceOf(FetchAuthTokenCache::class, $credentials); + + $this->assertEquals( + $this->quotaProject, + $credentials->getQuotaProject() + ); + } + + public function testWithGCECredentials() + { + $this->setHomeEnv(__DIR__ . '/not_exist_fixtures'); + $wantedTokens = [ + 'access_token' => '1/abdef1234567890', + 'expires_in' => '57', + 'token_type' => 'Bearer', + ]; + $jsonTokens = json_encode($wantedTokens); + + // simulate the response from GCE. + $httpHandler = $this->getHandler([ + new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']), + new Response(200, [], Utils::streamFor($jsonTokens)), + ]); + + $credentials = ApplicationDefaultCredentials::getCredentials( + null, + $httpHandler, + null, + null, + $this->quotaProject + ); + + $this->assertInstanceOf(GCECredentials::class, $credentials); + + $this->assertEquals( + $this->quotaProject, + $credentials->getQuotaProject() + ); + } + + public function testAppEngineStandard() + { + $_SERVER['SERVER_SOFTWARE'] = 'Google App Engine'; + $this->setHomeEnv(__DIR__ . '/not_exist_fixtures'); + $this->assertInstanceOf( + 'Google\Auth\Credentials\AppIdentityCredentials', + ApplicationDefaultCredentials::getCredentials() + ); + } + + public function testAppEngineFlexible() + { + $_SERVER['SERVER_SOFTWARE'] = 'Google App Engine'; + putenv('GAE_INSTANCE=aef-default-20180313t154438'); + $this->setHomeEnv(__DIR__ . '/not_exist_fixtures'); + $httpHandler = $this->getHandler([ + new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']), + ]); + $this->assertInstanceOf( + GCECredentials::class, + ApplicationDefaultCredentials::getCredentials(null, $httpHandler) + ); + } + + public function testAppEngineFlexibleIdToken() + { + $_SERVER['SERVER_SOFTWARE'] = 'Google App Engine'; + putenv('GAE_INSTANCE=aef-default-20180313t154438'); + $this->setHomeEnv(__DIR__ . '/not_exist_fixtures'); + $httpHandler = $this->getHandler([ + new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']), + ]); + $creds = ApplicationDefaultCredentials::getIdTokenCredentials( + $this->targetAudience, + $httpHandler + ); + $this->assertInstanceOf(GCECredentials::class, $creds); + } + + /** + * @dataProvider provideExternalAccountCredentials + */ + public function testExternalAccountCredentials(string $jsonFile, string $expectedCredSource) + { + putenv(sprintf('GOOGLE_APPLICATION_CREDENTIALS=%s/fixtures/fixtures6/%s', __DIR__, $jsonFile)); + + $creds = ApplicationDefaultCredentials::getCredentials('a_scope'); + + $this->assertInstanceOf(ExternalAccountCredentials::class, $creds); + + $credsReflection = new \ReflectionClass($creds); + $credsProp = $credsReflection->getProperty('auth'); + + $oauth = $credsProp->getValue($creds); + $oauthReflection = new \ReflectionClass($oauth); + $oauthProp = $oauthReflection->getProperty('subjectTokenFetcher'); + + $subjectTokenFetcher = $oauthProp->getValue($oauth); + $this->assertInstanceOf($expectedCredSource, $subjectTokenFetcher); + } + + public function testGetDefaultLoggerReturnStdOutLoggerIfEnvVarIsPresent() + { + putenv($this::SDK_DEBUG_ENV_VAR . '=true'); + $logger = ApplicationDefaultCredentials::getDefaultLogger(); + $this->assertTrue($logger instanceof StdOutLogger); + } + + public function testGetDefaultLoggerReturnsNullIfNotEnvVar() + { + putenv($this::SDK_DEBUG_ENV_VAR . '=false'); + $logger = ApplicationDefaultCredentials::getDefaultLogger(); + + $this->assertNull($logger); + + putenv($this::SDK_DEBUG_ENV_VAR . '=0'); + $logger = ApplicationDefaultCredentials::getDefaultLogger(); + + $this->assertNull($logger); + + putenv($this::SDK_DEBUG_ENV_VAR . '='); + $logger = ApplicationDefaultCredentials::getDefaultLogger(); + + $this->assertNull($logger); + } + + public function testGetDefaultLoggerRaiseAWarningIfMisconfiguredAndReturnsNull() + { + putenv($this::SDK_DEBUG_ENV_VAR . '=invalid'); + + $this->expectExceptionMessage( + 'The GOOGLE_SDK_PHP_LOGGING is set, but it is set to another value than false or true' + ); + + set_error_handler(static function (int $errno, string $errstr): never { + throw new \Exception($errstr, $errno); + }, E_USER_NOTICE); + + ApplicationDefaultCredentials::getDefaultLogger(); + } + + public function provideExternalAccountCredentials() + { + return [ + ['file_credentials.json', CredentialSource\FileSource::class], + ['url_credentials.json', CredentialSource\UrlSource::class], + ['aws_credentials.json', CredentialSource\AwsNativeSource::class], + ['executable_credentials.json', CredentialSource\ExecutableSource::class], + ]; + } + + /** @runInSeparateProcess */ + public function testUniverseDomainInKeyFile() + { + // Test no universe domain in keyfile defaults to "googleapis.com" + $keyFile = __DIR__ . '/fixtures/fixtures3/service_account_credentials.json'; + putenv(ServiceAccountCredentials::ENV_VAR . '=' . $keyFile); + $creds = ApplicationDefaultCredentials::getCredentials(); + $this->assertEquals(CredentialsLoader::DEFAULT_UNIVERSE_DOMAIN, $creds->getUniverseDomain()); + + // Test universe domain in "service_account" keyfile + $keyFile = __DIR__ . '/fixtures/fixtures1/private.json'; + putenv(ServiceAccountCredentials::ENV_VAR . '=' . $keyFile); + $creds = ApplicationDefaultCredentials::getCredentials(); + $this->assertEquals('example-universe.com', $creds->getUniverseDomain()); + + // Test universe domain in "authenticated_user" keyfile is not read. + $keyFile = __DIR__ . '/fixtures/fixtures2/private.json'; + putenv(ServiceAccountCredentials::ENV_VAR . '=' . $keyFile); + $creds2 = ApplicationDefaultCredentials::getCredentials(); + $this->assertEquals(CredentialsLoader::DEFAULT_UNIVERSE_DOMAIN, $creds2->getUniverseDomain()); + + // test passing in a different universe domain for "authenticated_user" has no effect. + $creds3 = ApplicationDefaultCredentials::getCredentials( + null, + null, + null, + null, + null, + null, + 'example-universe2.com' + ); + $this->assertEquals(CredentialsLoader::DEFAULT_UNIVERSE_DOMAIN, $creds3->getUniverseDomain()); + } + + /** @runInSeparateProcess */ + public function testUniverseDomainInGceCredentials() + { + $this->setHomeEnv(null); + + $expectedUniverseDomain = 'example-universe.com'; + $creds = ApplicationDefaultCredentials::getCredentials( + null, // $scope + $httpHandler = $this->getHandler([ + new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']), + new Response(200, [], Utils::streamFor($expectedUniverseDomain)), + ]) // $httpHandler + ); + $this->assertEquals('example-universe.com', $creds->getUniverseDomain($httpHandler)); + + // test passing in a different universe domain overrides metadata server + $creds2 = ApplicationDefaultCredentials::getCredentials( + null, // $scope + $httpHandler = $this->getHandler([ + new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']), + ]), // $httpHandler + null, // $cacheConfig + null, // $cache + null, // $quotaProject + null, // $defaultScope + 'example-universe2.com' // $universeDomain + ); + $this->assertEquals('example-universe2.com', $creds2->getUniverseDomain($httpHandler)); + + // test error response returns default universe domain + $creds2 = ApplicationDefaultCredentials::getCredentials( + null, // $scope + $httpHandler = $this->getHandler([ + new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']), + new Response(404), + ]), // $httpHandler + ); + $this->assertEquals( + CredentialsLoader::DEFAULT_UNIVERSE_DOMAIN, + $creds2->getUniverseDomain($httpHandler) + ); + } + + public function testRegionalAccessBoundaryLookupIntegration() + { + if ('true' !== getenv('RUN_TRUST_BOUNDARY_TESTS')) { + $this->markTestSkipped('This test requires RUN_TRUST_BOUNDARY_TESTS=true'); + } + + $creds = ApplicationDefaultCredentials::getCredentials( + 'https://www.googleapis.com/auth/cloud-platform', + enableRegionalAccessBoundary: true, + ); + + $mock = new MockHandler([ + new Response(200, [], '{"status":"it worked!"}') // response from KMS + ]); + + $container = []; + $history = Middleware::history($container); + + $middleware = new AuthTokenMiddleware($creds); + $stack = HandlerStack::create($mock); + $stack->push($middleware); + $stack->push($history); + + $client = new Client([ + 'handler' => $stack, + 'auth' => 'google_auth' + ]); + + $res = $client->get('https://fake.url/'); + $this->assertEquals('{"status":"it worked!"}', (string) $res->getBody()); + + $this->assertCount(1, $container); + $this->assertArrayHasKey('request', $container[0]); + + $request = $container[0]['request']; + $this->assertTrue($request->hasHeader('x-allowed-locations')); + $this->assertEquals('0x80000000000', $request->getHeaderLine('x-allowed-locations')); + } +} diff --git a/Auth/tests/BaseTest.php b/Auth/tests/BaseTest.php new file mode 100644 index 000000000000..80f0736ade95 --- /dev/null +++ b/Auth/tests/BaseTest.php @@ -0,0 +1,16 @@ +getValidKeyName + */ + public function getValidKeyName($key) + { + return preg_replace('|[^a-zA-Z0-9_\.! ]|', '', $key); + } +} diff --git a/Auth/tests/Cache/FileSystemCacheItemPoolTest.php b/Auth/tests/Cache/FileSystemCacheItemPoolTest.php new file mode 100644 index 000000000000..c6c9a4f209cd --- /dev/null +++ b/Auth/tests/Cache/FileSystemCacheItemPoolTest.php @@ -0,0 +1,225 @@ +', ',', '/', ' ', + ]; + + public function setUp(): void + { + $this->cachePath = sys_get_temp_dir() . '/google_auth_php_test/'; + $this->filesystem = new Filesystem(); + $this->filesystem->remove($this->cachePath); + $this->pool = new FileSystemCacheItemPool($this->cachePath); + } + + public function tearDown(): void + { + $this->filesystem->remove($this->cachePath); + } + + public function testInstanceCreatesCacheFolder() + { + $this->assertTrue(file_exists($this->cachePath)); + $this->assertTrue(is_dir($this->cachePath)); + } + + public function testSaveAndGetItem() + { + $item = $this->getNewItem(); + $item->expiresAfter(60); + $this->pool->save($item); + $retrievedItem = $this->pool->getItem($item->getKey()); + + $this->assertTrue($retrievedItem->isHit()); + $this->assertEquals($retrievedItem->get(), $item->get()); + } + + public function testHasItem() + { + $item = $this->getNewItem(); + $this->assertFalse($this->pool->hasItem($item->getKey())); + $this->pool->save($item); + $this->assertTrue($this->pool->hasItem($item->getKey())); + } + + public function testDeleteItem() + { + $item = $this->getNewItem(); + $this->pool->save($item); + + $this->assertTrue($this->pool->deleteItem($item->getKey())); + $this->assertFalse($this->pool->hasItem($item->getKey())); + } + + public function testDeleteItems() + { + $items = [ + $this->getNewItem(), + $this->getNewItem('NewItem2'), + $this->getNewItem('NewItem3') + ]; + + foreach ($items as $item) { + $this->pool->save($item); + } + + $itemKeys = array_map(fn ($item) => $item->getKey(), $items); + + $result = $this->pool->deleteItems($itemKeys); + $this->assertTrue($result); + } + + public function testGetItems() + { + $items = [ + $this->getNewItem(), + $this->getNewItem('NewItem2'), + $this->getNewItem('NewItem3') + ]; + + foreach ($items as $item) { + $this->pool->save($item); + } + + $keys = array_map(fn ($item) => $item->getKey(), $items); + array_push($keys, 'NonExistant'); + + $retrievedItems = $this->pool->getItems($keys); + + foreach ($items as $item) { + $this->assertTrue($retrievedItems[$item->getKey()]->isHit()); + } + + $this->assertFalse($retrievedItems['NonExistant']->isHit()); + } + + public function testClear() + { + $item = $this->getNewItem(); + $this->pool->save($item); + $this->assertLessThan(scandir($this->cachePath), 2); + $this->pool->clear(); + // Clear removes all the files, but scandir returns `.` and `..` as files + $this->assertEquals(count(scandir($this->cachePath)), 2); + } + + public function testSaveDeferredAndCommit() + { + $item = $this->getNewItem(); + $this->pool->saveDeferred($item); + $this->assertFalse($this->pool->getItem($item->getKey())->isHit()); + + $this->pool->commit(); + $this->assertTrue($this->pool->getItem($item->getKey())->isHit()); + } + + /** + * @dataProvider provideInvalidChars + */ + public function testGetItemWithIncorrectKeyShouldThrowAnException($char) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + "The key '$char' is not valid. The key should follow the pattern |^[a-zA-Z0-9_\.! ]+$|" + ); + $item = $this->getNewItem($char); + $this->pool->getItem($item->getKey()); + } + + /** + * @dataProvider provideInvalidChars + */ + public function testGetItemsWithIncorrectKeyShouldThrowAnException($char) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + "The key '$char' is not valid. The key should follow the pattern |^[a-zA-Z0-9_\.! ]+$|" + ); + $item = $this->getNewItem($char); + $this->pool->getItems([$item->getKey()]); + } + + /** + * @dataProvider provideInvalidChars + */ + public function testHasItemWithIncorrectKeyShouldThrowAnException($char) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + "The key '$char' is not valid. The key should follow the pattern |^[a-zA-Z0-9_\.! ]+$|" + ); + $item = $this->getNewItem($char); + $this->pool->hasItem($item->getKey()); + } + + /** + * @dataProvider provideInvalidChars + */ + public function testDeleteItemWithIncorrectKeyShouldThrowAnException($char) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + "The key '$char' is not valid. The key should follow the pattern |^[a-zA-Z0-9_\.! ]+$|" + ); + $item = $this->getNewItem($char); + $this->pool->deleteItem($item->getKey()); + } + + /** + * @dataProvider provideInvalidChars + */ + public function testDeleteItemsWithIncorrectKeyShouldThrowAnException($char) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + "The key '$char' is not valid. The key should follow the pattern |^[a-zA-Z0-9_\.! ]+$|" + ); + $item = $this->getNewItem($char); + $this->pool->deleteItems([$item->getKey()]); + } + + private function getNewItem(null|string $key = null): TypedItem + { + $item = new TypedItem($key ?? 'NewItem'); + $item->set('NewValue'); + + return $item; + } + + public function provideInvalidChars(): array + { + return array_map(fn ($char) => [$char], $this->invalidChars); + } +} diff --git a/Auth/tests/Cache/ItemTest.php b/Auth/tests/Cache/ItemTest.php new file mode 100644 index 000000000000..08902599c69a --- /dev/null +++ b/Auth/tests/Cache/ItemTest.php @@ -0,0 +1,121 @@ +assertEquals('item', $item->getKey()); + } + + public function testGetsNull() + { + $item = new TypedItem('item'); + + $this->assertNull($item->get()); + $this->assertFalse($item->isHit()); + } + + public function testGetsValue() + { + $value = 'value'; + $item = new TypedItem('item'); + $item->set($value); + + $this->assertEquals('value', $item->get()); + } + + /** + * @dataProvider values + */ + public function testSetsValue($value) + { + $item = new TypedItem('item'); + $item->set($value); + + $this->assertEquals($value, $item->get()); + } + + public function values() + { + return [ + [1], + [1.5], + [true], + [null], + [new \DateTime()], + [['test']], + ['value'] + ]; + } + + public function testIsHit() + { + $item = new TypedItem('item'); + + $this->assertFalse($item->isHit()); + + $item->set('value'); + + $this->assertTrue($item->isHit()); + } + + public function testExpiresAt() + { + $item = new TypedItem('item'); + $item->set('value'); + $item->expiresAt(new \DateTime('now + 1 hour')); + + $this->assertTrue($item->isHit()); + + $item->expiresAt(null); + + $this->assertTrue($item->isHit()); + + $item->expiresAt(new \DateTime('yesterday')); + + $this->assertFalse($item->isHit()); + } + + public function testExpiresAfter() + { + $item = new TypedItem('item'); + $item->set('value'); + $item->expiresAfter(30); + + $this->assertTrue($item->isHit()); + + $item->expiresAfter(0); + + $this->assertFalse($item->isHit()); + + $item->expiresAfter(new \DateInterval('PT30S')); + + $this->assertTrue($item->isHit()); + + $item->expiresAfter(null); + + $this->assertTrue($item->isHit()); + } +} diff --git a/Auth/tests/Cache/MemoryCacheItemPoolTest.php b/Auth/tests/Cache/MemoryCacheItemPoolTest.php new file mode 100644 index 000000000000..6ae2a998c322 --- /dev/null +++ b/Auth/tests/Cache/MemoryCacheItemPoolTest.php @@ -0,0 +1,228 @@ +pool = new MemoryCacheItemPool(); + } + + public function saveItem($key, $value) + { + $item = $this->pool->getItem($key); + $item->set($value); + $this->assertTrue($this->pool->save($item)); + + return $item; + } + + public function testGetsFreshItem() + { + $item = $this->pool->getItem('item'); + + $this->assertInstanceOf(TypedItem::class, $item); + $this->assertNull($item->get()); + $this->assertFalse($item->isHit()); + } + + public function testGetsExistingItem() + { + $key = 'item'; + $value = 'value'; + $this->saveItem($key, $value); + $item = $this->pool->getItem($key); + + $this->assertInstanceOf(TypedItem::class, $item); + $this->assertEquals($value, $item->get()); + $this->assertTrue($item->isHit()); + } + + public function testGetsMultipleItems() + { + $keys = ['item1', 'item2']; + $items = $this->pool->getItems($keys); + + $this->assertEquals($keys, array_keys($items)); + $this->assertContainsOnlyInstancesOf(TypedItem::class, $items); + } + + public function testHasItem() + { + $existsKey = 'does-exist'; + $this->saveItem($existsKey, 'value'); + + $this->assertTrue($this->pool->hasItem($existsKey)); + $this->assertFalse($this->pool->hasItem('does-not-exist')); + } + + public function testClear() + { + $key = 'item'; + $this->saveItem($key, 'value'); + + $this->assertTrue($this->pool->hasItem($key)); + $this->assertTrue($this->pool->clear()); + $this->assertFalse($this->pool->hasItem($key)); + } + + public function testDeletesItem() + { + $key = 'item'; + $this->saveItem($key, 'value'); + + $this->assertTrue($this->pool->deleteItem($key)); + $this->assertFalse($this->pool->hasItem($key)); + } + + public function testDeletesItems() + { + $keys = ['item1', 'item2']; + + foreach ($keys as $key) { + $this->saveItem($key, 'value'); + } + + $this->assertTrue($this->pool->deleteItems($keys)); + $this->assertFalse($this->pool->hasItem($keys[0])); + $this->assertFalse($this->pool->hasItem($keys[1])); + } + + public function testDoesNotDeleteItemsWithInvalidKey() + { + $keys = ['item1', '{item2}', 'item3']; + $value = 'value'; + $this->saveItem($keys[0], $value); + $this->saveItem($keys[2], $value); + + try { + $this->pool->deleteItems($keys); + } catch (InvalidArgumentException $ex) { + // continue execution + } + + $this->assertTrue($this->pool->hasItem($keys[0])); + $this->assertTrue($this->pool->hasItem($keys[2])); + } + + public function testSavesItem() + { + $key = 'item'; + $this->saveItem($key, 'value'); + + $this->assertTrue($this->pool->hasItem($key)); + } + + public function testSavesDeferredItem() + { + $item = $this->pool->getItem('item'); + $this->assertTrue($this->pool->saveDeferred($item)); + } + + public function testCommitsDeferredItems() + { + $keys = ['item1', 'item2']; + + foreach ($keys as $key) { + $item = $this->pool->getItem($key); + $item->set('value'); + $this->pool->saveDeferred($item); + } + + $this->assertTrue($this->pool->commit()); + $this->assertTrue($this->pool->hasItem($keys[0])); + $this->assertTrue($this->pool->hasItem($keys[1])); + } + + /** + * @dataProvider invalidKeys + */ + public function testCheckInvalidKeysOnGetItem($key) + { + $this->expectException(InvalidArgumentException::class); + + $this->pool->getItem($key); + } + + /** + * @dataProvider invalidKeys + */ + public function testCheckInvalidKeysOnGetItems($key) + { + $this->expectException(InvalidArgumentException::class); + + $this->pool->getItems([$key]); + } + + /** + * @dataProvider invalidKeys + */ + public function testCheckInvalidKeysOnHasItem($key) + { + $this->expectException(InvalidArgumentException::class); + + $this->pool->hasItem($key); + } + + /** + * @dataProvider invalidKeys + */ + public function testCheckInvalidKeysOnDeleteItem($key) + { + $this->expectException(InvalidArgumentException::class); + + $this->pool->deleteItem($key); + } + + /** + * @dataProvider invalidKeys + */ + public function testCheckInvalidKeysOnDeleteItems($key) + { + $this->expectException(InvalidArgumentException::class); + + $this->pool->deleteItems([$key]); + } + + public function invalidKeys() + { + return [ + [1], + [true], + [null], + [new \DateTime()], + ['{'], + ['}'], + ['('], + [')'], + ['/'], + ['\\'], + ['@'], + [':'], + [[]] + ]; + } +} diff --git a/Auth/tests/Cache/RaceConditionTest.php b/Auth/tests/Cache/RaceConditionTest.php new file mode 100644 index 000000000000..34d8b4249fea --- /dev/null +++ b/Auth/tests/Cache/RaceConditionTest.php @@ -0,0 +1,142 @@ +remove(self::$cachePath); + } + + /** + * @runInSeparateProcess + * @dataProvider provideRaceCondition + */ + public function testRaceCondition(string $cacheClass) + { + if (!function_exists('pcntl_fork')) { + $this->markTestSkipped('pcntl_fork is not available'); + } + for ($i = 0; $i < 50; $i++) { + // SysV Cache warmup to prevent segment creation race + if ($cacheClass === SysVCacheItemPool::class) { + $warmupPool = $this->createCacheItemPool($cacheClass, $i); + $warmupItem = $warmupPool->getItem('warmup'); + $warmupItem->set('ok'); + $warmupPool->save($warmupItem); + unset($warmupPool); + } + + $pids = []; + for ($j = 0; $j < 4; $j++) { + $pid = pcntl_fork(); + if ($pid == -1) { + $this->fail('Could not fork'); + } + + // Always create a new pool instance inside the loop (matches original) + $pool = $this->createCacheItemPool($cacheClass, $i); + $item = $pool->getItem('foo'); + $item->set('bar'); + $this->assertTrue($pool->save($item)); + + if ($pid) { + // parent + $pids[] = $pid; + if ($cacheClass === SysVCacheItemPool::class) { + // For SysV, we must destroy the parent's pool object immediately + // so it is not inherited by the next child process. + unset($pool); + } + } else { + // child + exit(0); + } + } + + // parent final save (matching original test logic) + // Note: for SysV, $pool was unset inside the loop, so we must recreate it. + // For FileSystem/Memory, $pool is still the one from the last iteration ($j=3). + if ($cacheClass === SysVCacheItemPool::class) { + $pool = $this->createCacheItemPool($cacheClass, $i); + // We need to re-get the item for this new pool + $item = $pool->getItem('foo'); + $item->set('bar'); + } + $this->assertTrue($pool->save($item)); + + foreach ($pids as $pid) { + pcntl_waitpid($pid, $status); + $this->assertEquals(0, $status); + } + + $this->assertTrue($pool->hasItem('foo')); + $cachedItem = $pool->getItem('foo'); + $this->assertEquals('bar', $cachedItem->get()); + + $pool->clear(); + unset($pool); + } + } + + public function createCacheItemPool(string $cacheClass, int $iteration = 0): CacheItemPoolInterface + { + switch ($cacheClass) { + case FileSystemCacheItemPool::class: + $cachePath = self::$cachePath . '/google_auth_php_test-' . rand(); + return new FileSystemCacheItemPool($cachePath); + case MemoryCacheItemPool::class: + return new MemoryCacheItemPool(); + case SysVCacheItemPool::class: + return new SysVCacheItemPool([ + 'proj' => chr(65 + ($iteration % 26)), + 'semProj' => chr(97 + ($iteration % 26)) + ]); + } + + throw new \Exception('Unrecognized cache class: ' . $cacheClass); + } + + public function provideRaceCondition() + { + return [ + [FileSystemCacheItemPool::class], + [MemoryCacheItemPool::class], + [SysVCacheItemPool::class], + ]; + } + + public static function tearDownAfterClass(): void + { + // remove all files generated from the filecaches + self::$filesystem->remove(self::$cachePath); + } +} diff --git a/Auth/tests/Cache/SysVCacheItemPoolTest.php b/Auth/tests/Cache/SysVCacheItemPoolTest.php new file mode 100644 index 000000000000..d85e60152f13 --- /dev/null +++ b/Auth/tests/Cache/SysVCacheItemPoolTest.php @@ -0,0 +1,205 @@ +markTestSkipped( + 'sysvshm extension is required for running the test' + ); + } + $this->pool = new SysVCacheItemPool(['variableKey' => self::VARIABLE_KEY]); + $this->pool->clear(); + } + + public function tearDown(): void + { + if (extension_loaded('sysvshm')) { + $this->pool->clear(); + } + } + + public function saveItem($key, $value) + { + $item = $this->pool->getItem($key); + $item->set($value); + $this->assertTrue($this->pool->save($item)); + + return $item; + } + + public function testGetsFreshItem() + { + $item = $this->pool->getItem('item'); + + $this->assertInstanceOf(TypedItem::class, $item); + $this->assertNull($item->get()); + $this->assertFalse($item->isHit()); + } + + public function testCacheAmongProcesses() + { + $expectedValue = 'val-' . rand(); + exec(sprintf('php %s/sysv_cache_creator.php %s', __DIR__, $expectedValue)); + $this->assertEquals( + $expectedValue, + $this->pool->getItem('separate-process-item')->get() + ); + } + + public function testGetsExistingItem() + { + $key = 'item'; + $value = 'value'; + $this->saveItem($key, $value); + $item = $this->pool->getItem($key); + + $this->assertInstanceOf(TypedItem::class, $item); + $this->assertEquals($value, $item->get()); + $this->assertTrue($item->isHit()); + } + + public function testGetsMultipleItems() + { + $keys = ['item1', 'item2']; + $items = $this->pool->getItems($keys); + + $this->assertEquals($keys, array_keys($items)); + $this->assertContainsOnlyInstancesOf(TypedItem::class, $items); + } + + public function testHasItem() + { + $existsKey = 'does-exist'; + $this->saveItem($existsKey, 'value'); + + $this->assertTrue($this->pool->hasItem($existsKey)); + $this->assertFalse($this->pool->hasItem('does-not-exist')); + } + + public function testClear() + { + $key = 'item'; + $this->saveItem($key, 'value'); + + $this->assertTrue($this->pool->hasItem($key)); + $this->assertTrue($this->pool->clear()); + $this->assertFalse($this->pool->hasItem($key)); + } + + public function testDeletesItem() + { + $key = 'item'; + $this->saveItem($key, 'value'); + + $this->assertTrue($this->pool->deleteItem($key)); + $this->assertFalse($this->pool->hasItem($key)); + } + + public function testDeletesItems() + { + $keys = ['item1', 'item2']; + + foreach ($keys as $key) { + $this->saveItem($key, 'value'); + } + + $this->assertTrue($this->pool->deleteItems($keys)); + $this->assertFalse($this->pool->hasItem($keys[0])); + $this->assertFalse($this->pool->hasItem($keys[1])); + } + + public function testSavesItem() + { + $key = 'item'; + $this->saveItem($key, 'value'); + + $this->assertTrue($this->pool->hasItem($key)); + } + + public function testSavesDeferredItem() + { + $item = $this->pool->getItem('item'); + $this->assertTrue($this->pool->saveDeferred($item)); + } + + public function testCommitsDeferredItems() + { + $keys = ['item1', 'item2']; + + foreach ($keys as $key) { + $item = $this->pool->getItem($key); + $item->set('value'); + $this->pool->saveDeferred($item); + } + + $this->assertTrue($this->pool->commit()); + $this->assertTrue($this->pool->hasItem($keys[0])); + $this->assertTrue($this->pool->hasItem($keys[1])); + $this->assertEquals( + $item->get(), + $this->pool->getItem($keys[1])->get() + ); + } + + public function testRaceCondition() + { + if (!extension_loaded('sysvsem')) { + $this->markTestSkipped( + 'sysvsem extension is required for running the race condition test' + ); + } + + $key = 'race-item'; + $initialValue = 0; + $this->saveItem($key, $initialValue); + + $numProcesses = 100; + $processes = []; + for ($i = 0; $i < $numProcesses; $i++) { + $command = sprintf( + 'php %s/sysv_cache_race_condition_writer.php %s %s', + __DIR__, + $key, + self::VARIABLE_KEY + ); + $processes[] = proc_open($command, [], $pipes); + } + + foreach ($processes as $process) { + // proc_close waits for the process to terminate and returns its exit code. + // This ensures that all child processes have completed their writes + // before the parent process proceeds to read the final value. + proc_close($process); + } + + $finalValue = $this->pool->getItem($key)->get(); + $this->assertEquals($numProcesses, $finalValue); + } +} diff --git a/Auth/tests/Cache/sysv_cache_creator.php b/Auth/tests/Cache/sysv_cache_creator.php new file mode 100644 index 000000000000..5367a405539e --- /dev/null +++ b/Auth/tests/Cache/sysv_cache_creator.php @@ -0,0 +1,34 @@ + 99]); +$item = new TypedItem('separate-process-item'); +$item->set($value); +$pool->save($item); diff --git a/Auth/tests/Cache/sysv_cache_race_condition_writer.php b/Auth/tests/Cache/sysv_cache_race_condition_writer.php new file mode 100644 index 000000000000..d241173dc753 --- /dev/null +++ b/Auth/tests/Cache/sysv_cache_race_condition_writer.php @@ -0,0 +1,30 @@ + $argv[2]]); + +$key = $argv[1]; + +$semKey = ftok(__FILE__, 'B'); +$semId = sem_get($semKey); +if (sem_acquire($semId)) { + $item = $pool->getItem($key); + $value = (int) $item->get(); + $value++; + usleep(10000); // Simulate some work + $item->set($value); + $pool->save($item); + + sem_release($semId); +} diff --git a/Auth/tests/CacheTraitTest.php b/Auth/tests/CacheTraitTest.php new file mode 100644 index 000000000000..a98ba8275a26 --- /dev/null +++ b/Auth/tests/CacheTraitTest.php @@ -0,0 +1,185 @@ +mockFetcher = $this->prophesize('Google\Auth\FetchAuthTokenInterface'); + $this->mockCacheItem = $this->prophesize('Psr\Cache\CacheItemInterface'); + $this->mockCache = $this->prophesize('Psr\Cache\CacheItemPoolInterface'); + } + + public function testSuccessfullyPullsFromCache() + { + $expectedValue = '1234'; + $this->mockCacheItem->isHit() + ->shouldBeCalledTimes(1) + ->willReturn(true); + $this->mockCacheItem->get() + ->shouldBeCalledTimes(1) + ->willReturn($expectedValue); + $this->mockCache->getItem(Argument::type('string')) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + + $implementation = new CacheTraitImplementation([ + 'cache' => $this->mockCache->reveal(), + ]); + + $cachedValue = $implementation->getCachedValue('key'); + $this->assertEquals($expectedValue, $cachedValue); + } + + public function testSuccessfullyPullsFromCacheWithInvalidKey() + { + $key = 'this-key-has-@-illegal-characters'; + $expectedKey = 'thiskeyhasillegalcharacters'; + $expectedValue = '1234'; + $this->mockCacheItem->isHit() + ->shouldBeCalledTimes(1) + ->willReturn(true); + $this->mockCacheItem->get() + ->shouldBeCalledTimes(1) + ->willReturn($expectedValue); + $this->mockCache->getItem($expectedKey) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + + $implementation = new CacheTraitImplementation([ + 'cache' => $this->mockCache->reveal(), + ]); + + $cachedValue = $implementation->getCachedValue($key); + $this->assertEquals($expectedValue, $cachedValue); + } + + public function testSuccessfullyPullsFromCacheWithLongKey() + { + $key = 'this-key-is-over-64-characters-and-it-will-still-work' + . '-but-it-will-be-hashed-and-shortened'; + $expectedKey = str_replace('-', '', $key); + $expectedKey = substr(hash('sha256', $expectedKey), 0, 64); + $expectedValue = '1234'; + $this->mockCacheItem->isHit() + ->shouldBeCalledTimes(1) + ->willReturn(true); + $this->mockCacheItem->get() + ->shouldBeCalledTimes(1) + ->willReturn($expectedValue); + $this->mockCache->getItem($expectedKey) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + + $implementation = new CacheTraitImplementation([ + 'cache' => $this->mockCache->reveal(), + ]); + + $cachedValue = $implementation->getCachedValue($key); + $this->assertEquals($expectedValue, $cachedValue); + } + + public function testFailsPullFromCacheWithNoCache() + { + $implementation = new CacheTraitImplementation(); + + $cachedValue = $implementation->getCachedValue('key'); + $this->assertEquals(null, $cachedValue); + } + + public function testFailsPullFromCacheWithoutKey() + { + $implementation = new CacheTraitImplementation([ + 'cache' => $this->mockCache->reveal(), + ]); + + $cachedValue = $implementation->getCachedValue(null); + $this->assertEquals(null, $cachedValue); + } + + public function testSuccessfullySetsToCache() + { + $value = '1234'; + $this->mockCacheItem->set($value) + ->shouldBeCalled() + ->willReturn($this->mockCacheItem->reveal()); + $this->mockCacheItem->expiresAfter(Argument::any()) + ->shouldBeCalled() + ->willReturn($this->mockCacheItem->reveal()); + $this->mockCache->getItem('key') + ->willReturn($this->mockCacheItem->reveal()); + $this->mockCache->save(Argument::type('Psr\Cache\CacheItemInterface')) + ->shouldBeCalled(); + + $implementation = new CacheTraitImplementation([ + 'cache' => $this->mockCache->reveal(), + ]); + + $implementation->setCachedValue('key', $value); + } + + public function testFailsSetToCacheWithNoCache() + { + $implementation = new CacheTraitImplementation(); + + $implementation->setCachedValue('key', '1234'); + + $cachedValue = $implementation->getCachedValue('key', '1234'); + $this->assertNull($cachedValue); + } + + public function testFailsSetToCacheWithoutKey() + { + $implementation = new CacheTraitImplementation([ + 'cache' => $this->mockCache->reveal(), + 'key' => null, + ]); + + $cachedValue = $implementation->setCachedValue(null, '1234'); + $this->assertNull($cachedValue); + } +} + +class CacheTraitImplementation +{ + use CacheTrait { + getCachedValue as public; + setCachedValue as public; + } + + public function __construct(array $config = []) + { + $this->cache = $config['cache'] ?? null; + $this->cacheConfig = [ + 'prefix' => '', + 'lifetime' => 1000, + ]; + } +} diff --git a/Auth/tests/CredentialSource/AwsNativeSourceTest.php b/Auth/tests/CredentialSource/AwsNativeSourceTest.php new file mode 100644 index 000000000000..664c1ec33d6a --- /dev/null +++ b/Auth/tests/CredentialSource/AwsNativeSourceTest.php @@ -0,0 +1,610 @@ +assertEquals('GET', $request->getMethod()); + $this->assertEquals($this->regionUrl, (string) $request->getUri()); + + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn('us-east-2b'); + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + + return $response->reveal(); + }; + + $region = AwsNativeSource::getRegionFromUrl($httpHandler, $this->regionUrl, []); + $this->assertEquals('us-east-2', $region); + } + + /** @runInSeparateProcess */ + public function testGetRegionFromEnv() + { + // Without any environment variables set, getRegionFromEnv should return null + $this->assertNull(AwsNativeSource::getRegionFromEnv()); + + // Requires AWS_REGION or AWS_DEFAULT_REGION to be set + putenv('AWS_REGION=aws-region'); + $this->assertEquals('aws-region', AwsNativeSource::getRegionFromEnv()); + + // Setting the default region does not hvae an effect + putenv('AWS_DEFAULT_REGION=aws-default-region'); + $this->assertEquals('aws-region', AwsNativeSource::getRegionFromEnv()); + + // Unsetting the AWS_REGION uses AWS_DEFAULT_REGION instead + putenv('AWS_REGION='); + $this->assertEquals('aws-default-region', AwsNativeSource::getRegionFromEnv()); + } + + public function testGetRoleName() + { + $httpHandler = function (RequestInterface $request): ResponseInterface { + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals($this->securityCredentialsUrl, (string) $request->getUri()); + + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn('expected-role-name'); + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + + return $response->reveal(); + }; + + $roleName = AwsNativeSource::getRoleName($httpHandler, $this->securityCredentialsUrl, []); + + $this->assertEquals('expected-role-name', $roleName); + } + + public function testGetImdsV2SessionToken() + { + $imdsV2Url = 'http://some-metadata-url/latest/api/token'; + $httpHandler = function (RequestInterface $request) use ($imdsV2Url): ResponseInterface { + $this->assertEquals('PUT', $request->getMethod()); + $this->assertEquals($imdsV2Url, (string) $request->getUri()); + $this->assertEquals('21600', $request->getHeaderLine('X-aws-ec2-metadata-token-ttl-seconds')); + + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn('expected-aws-token'); + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + + return $response->reveal(); + }; + + $roleName = AwsNativeSource::getImdsV2SessionToken($imdsV2Url, $httpHandler); + + $this->assertEquals('expected-aws-token', $roleName); + } + + public function testGetSigningVarsFromUrl() + { + $httpHandler = function (RequestInterface $request): ResponseInterface { + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals( + $this->securityCredentialsUrl . '/test-role-name', + (string) $request->getUri() + ); + + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn(json_encode([ + 'AccessKeyId' => 'expected-access-key-id', + 'SecretAccessKey' => 'expected-secret-access-key', + 'Token' => 'expected-token', + ])); + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + + return $response->reveal(); + }; + + $signingVars = AwsNativeSource::getSigningVarsFromUrl( + $httpHandler, + $this->securityCredentialsUrl, + 'test-role-name', + [] + ); + + $this->assertEquals('expected-access-key-id', $signingVars[0]); + $this->assertEquals('expected-secret-access-key', $signingVars[1]); + $this->assertEquals('expected-token', $signingVars[2]); + } + + /** @runInSeparateProcess */ + public function testGetSigningVarsFromEnv() + { + // Without any environment variables set, getSigningVarsFromEnv should return null + $signingVars = AwsNativeSource::getSigningVarsFromEnv(); + + $this->assertNull($signingVars); + + // Requires AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY to be set + putenv('AWS_ACCESS_KEY_ID=expected-access-key-id'); + putenv('AWS_SECRET_ACCESS_KEY=expected-secret-access-key'); + + $signingVars = AwsNativeSource::getSigningVarsFromEnv(); + + $this->assertEquals('expected-access-key-id', $signingVars[0]); + $this->assertEquals('expected-secret-access-key', $signingVars[1]); + $this->assertNull($signingVars[2]); + + // AWS_SESSION_TOKEN is optional + putenv('AWS_SESSION_TOKEN=expected-session-token'); + + $signingVars = AwsNativeSource::getSigningVarsFromEnv(); + $this->assertEquals('expected-access-key-id', $signingVars[0]); + $this->assertEquals('expected-secret-access-key', $signingVars[1]); + $this->assertEquals('expected-session-token', $signingVars[2]); + } + + public function testGetSignedRequestHeaders() + { + $region = 'us-east-2'; + $host = 'sts.us-east-2.amazonaws.com'; + $accessKeyId = 'expected-access-key-id'; + $secretAccessKey = 'expected-secret-access-key'; + $securityToken = null; + $headers = AwsNativeSource::getSignedRequestHeaders( + $host, + $region, + $accessKeyId, + $secretAccessKey, + $securityToken + ); + + $this->assertArrayHasKey('x-amz-date', $headers); + $this->assertArrayHasKey('Authorization', $headers); + $this->assertArrayNotHasKey('x-amz-security-token', $headers); + $this->assertStringStartsWith('AWS4-HMAC-SHA256 ', $headers['Authorization']); + $this->assertStringContainsString( + ' Credential=expected-access-key-id/', + $headers['Authorization'] + ); + $this->assertStringContainsString( + '/sts/aws4_request, SignedHeaders=host;x-amz-date, ', + $headers['Authorization'] + ); + $this->assertStringContainsString( + ', Signature=', + $headers['Authorization'] + ); + + $securityToken = 'extected-security-token'; + $headers = AwsNativeSource::getSignedRequestHeaders( + $region, + $host, + $accessKeyId, + $secretAccessKey, + $securityToken + ); + + $this->assertArrayHasKey('x-amz-date', $headers); + $this->assertArrayHasKey('Authorization', $headers); + $this->assertArrayHasKey('x-amz-security-token', $headers); + $this->assertStringStartsWith('AWS4-HMAC-SHA256 ', $headers['Authorization']); + $this->assertStringContainsString( + ' Credential=expected-access-key-id/', + $headers['Authorization'] + ); + $this->assertStringContainsString( + '/sts/aws4_request, SignedHeaders=host;x-amz-date;x-amz-security-token, ', + $headers['Authorization'] + ); + $this->assertStringContainsString( + ', Signature=', + $headers['Authorization'] + ); + } + + public function testFetchSubjectTokenWithoutSecurityCredentialsUrlOrEnvThrowsException() + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage( + 'Unable to get credentials from ENV, and no security credentials URL provided' + ); + + $aws = new AwsNativeSource( + $this->audience, + $this->regionUrl, + $this->regionalCredVerificationUrl, + ); + $httpHandler = function (RequestInterface $request): ResponseInterface { + // Mock response from AWS Metadata Server + $awsTokenBody = $this->prophesize(StreamInterface::class); + $awsTokenBody->__toString()->willReturn('aws-token'); + $awsTokenResponse = $this->prophesize(ResponseInterface::class); + $awsTokenResponse->getBody()->willReturn($awsTokenBody->reveal()); + return $awsTokenResponse->reveal(); + }; + $aws->fetchSubjectToken($httpHandler); + } + + /** @runInSeparateProcess */ + public function testGetSigningVarsFromEcsWithRelativeUri() + { + putenv('AWS_CONTAINER_CREDENTIALS_RELATIVE_URI=/v2/credentials/test'); + + $httpHandler = function (RequestInterface $request): ResponseInterface { + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals( + 'http://169.254.170.2/v2/credentials/test', + (string) $request->getUri() + ); + + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn(json_encode([ + 'AccessKeyId' => 'expected-access-key-id', + 'SecretAccessKey' => 'expected-secret-access-key', + 'Token' => 'expected-token', + ])); + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + + return $response->reveal(); + }; + + $signingVars = AwsNativeSource::getSigningVarsFromEcs($httpHandler); + + $this->assertEquals('expected-access-key-id', $signingVars[0]); + $this->assertEquals('expected-secret-access-key', $signingVars[1]); + $this->assertEquals('expected-token', $signingVars[2]); + } + + /** @runInSeparateProcess */ + public function testGetSigningVarsFromEcsWithFullUri() + { + putenv('AWS_CONTAINER_CREDENTIALS_FULL_URI=http://localhost:8080/credentials'); + + $httpHandler = function (RequestInterface $request): ResponseInterface { + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals( + 'http://localhost:8080/credentials', + (string) $request->getUri() + ); + + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn(json_encode([ + 'AccessKeyId' => 'expected-access-key-id', + 'SecretAccessKey' => 'expected-secret-access-key', + ])); + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + + return $response->reveal(); + }; + + $signingVars = AwsNativeSource::getSigningVarsFromEcs($httpHandler); + + $this->assertEquals('expected-access-key-id', $signingVars[0]); + $this->assertEquals('expected-secret-access-key', $signingVars[1]); + $this->assertNull($signingVars[2]); + } + + /** @runInSeparateProcess */ + public function testGetSigningVarsFromEcsWithAuthToken() + { + putenv('AWS_CONTAINER_CREDENTIALS_FULL_URI=http://localhost:8080/credentials'); + putenv('AWS_CONTAINER_AUTHORIZATION_TOKEN=auth-token-123'); + + $httpHandler = function (RequestInterface $request): ResponseInterface { + $this->assertEquals('auth-token-123', $request->getHeaderLine('Authorization')); + + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn(json_encode([ + 'AccessKeyId' => 'expected-access-key-id', + 'SecretAccessKey' => 'expected-secret-access-key', + ])); + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + + return $response->reveal(); + }; + + AwsNativeSource::getSigningVarsFromEcs($httpHandler); + } + + /** @runInSeparateProcess */ + public function testGetSigningVarsFromEcsWithAuthTokenFile() + { + $tokenFile = tempnam(sys_get_temp_dir(), 'aws_token'); + file_put_contents($tokenFile, 'auth-token-file-123'); + + putenv('AWS_CONTAINER_CREDENTIALS_FULL_URI=http://localhost:8080/credentials'); + putenv('AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE=' . $tokenFile); + putenv('AWS_CONTAINER_AUTHORIZATION_TOKEN=auth-token-123'); // File should take precedence + + $httpHandler = function (RequestInterface $request): ResponseInterface { + $this->assertEquals('auth-token-file-123', $request->getHeaderLine('Authorization')); + + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn(json_encode([ + 'AccessKeyId' => 'expected-access-key-id', + 'SecretAccessKey' => 'expected-secret-access-key', + ])); + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + + return $response->reveal(); + }; + + try { + AwsNativeSource::getSigningVarsFromEcs($httpHandler); + } finally { + if (file_exists($tokenFile)) { + unlink($tokenFile); + } + } + } + + /** @runInSeparateProcess */ + public function testGetSigningVarsFromEcsWithUnreadableAuthTokenFile() + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Token file /does/not/exist/token is not readable'); + + putenv('AWS_CONTAINER_CREDENTIALS_FULL_URI=http://localhost:8080/credentials'); + putenv('AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE=/does/not/exist/token'); + + $httpHandler = function (RequestInterface $request): ResponseInterface { + $this->fail('HTTP handler should not be called'); + }; + + AwsNativeSource::getSigningVarsFromEcs($httpHandler); + } + + /** @runInSeparateProcess */ + public function testGetSigningVarsFromEcsThrowsUnexpectedValueExceptionOnInvalidResponse() + { + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Invalid or missing ECS credentials in response'); + + putenv('AWS_CONTAINER_CREDENTIALS_FULL_URI=http://localhost:8080/credentials'); + + $httpHandler = function (RequestInterface $request): ResponseInterface { + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn(json_encode(['invalid' => 'response'])); + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + + return $response->reveal(); + }; + + AwsNativeSource::getSigningVarsFromEcs($httpHandler); + } + + /** @runInSeparateProcess */ + public function testGetSigningVarsFromEcsThrowsExceptionOnServerError() + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Server error'); + + putenv('AWS_CONTAINER_CREDENTIALS_FULL_URI=http://localhost:8080/credentials'); + + $httpHandler = function (RequestInterface $request): ResponseInterface { + throw new \RuntimeException('Server error'); + }; + + AwsNativeSource::getSigningVarsFromEcs($httpHandler); + } + + /** @runInSeparateProcess */ + public function testGetSigningVarsFromEcsReturnsNullWhenUrisNotSet() + { + // No environment variables set + $httpHandler = function (RequestInterface $request): ResponseInterface { + $this->fail('HTTP handler should not be called'); + }; + + $signingVars = AwsNativeSource::getSigningVarsFromEcs($httpHandler); + + $this->assertNull($signingVars); + } + + /** + * @runInSeparateProcess + */ + public function testFetchSubjectTokenFromEcs() + { + $aws = new AwsNativeSource( + $this->audience, + $this->regionUrl, + $this->regionalCredVerificationUrl, + ); + + putenv('AWS_CONTAINER_CREDENTIALS_RELATIVE_URI=/v2/credentials/test'); + + // Mock response from AWS ECS Metadata Server + $awsTokenBody = $this->prophesize(StreamInterface::class); + $awsTokenBody->__toString()->willReturn(json_encode([ + 'AccessKeyId' => 'expected-access-key-id', + 'SecretAccessKey' => 'expected-secret-access-key', + 'Token' => 'expected-token', + ])); + $awsTokenResponse = $this->prophesize(ResponseInterface::class); + $awsTokenResponse->getBody()->willReturn($awsTokenBody->reveal()); + + // Mock response from Region URL + $regionBody = $this->prophesize(StreamInterface::class); + $regionBody->__toString()->willReturn('us-east-2b'); + $regionResponse = $this->prophesize(ResponseInterface::class); + $regionResponse->getBody()->willReturn($regionBody->reveal()); + + $requestCount = 0; + $httpHandler = function (RequestInterface $request) use ( + $awsTokenResponse, + $regionResponse, + &$requestCount + ): ResponseInterface { + $requestCount++; + switch ($requestCount) { + case 1: + return $awsTokenResponse->reveal(); + case 2: + return $regionResponse->reveal(); + } + throw new \Exception('Unexpected request'); + }; + + $subjectToken = $aws->fetchSubjectToken($httpHandler); + $unserializedToken = json_decode(urldecode($subjectToken), true); + $this->assertArrayHasKey('headers', $unserializedToken); + $this->assertArrayHasKey('method', $unserializedToken); + $this->assertArrayHasKey('url', $unserializedToken); + } + + /** + * @runInSeparateProcess + */ + public function testFetchSubjectTokenFromEnv() + { + $aws = new AwsNativeSource( + $this->audience, + $this->regionUrl, + $this->regionalCredVerificationUrl, + ); + + // Set minimum number of environment variables required + putenv('AWS_ACCESS_KEY_ID=expected-access-key-id'); + putenv('AWS_SECRET_ACCESS_KEY=expected-secret-access-key'); + + // Mock response from AWS Metadata Server + $awsTokenBody = $this->prophesize(StreamInterface::class); + $awsTokenBody->__toString()->willReturn('aws-token'); + $awsTokenResponse = $this->prophesize(ResponseInterface::class); + $awsTokenResponse->getBody()->willReturn($awsTokenBody->reveal()); + + // Mock response from Region URL + $regionBody = $this->prophesize(StreamInterface::class); + $regionBody->__toString()->willReturn('us-east-2b'); + $regionResponse = $this->prophesize(ResponseInterface::class); + $regionResponse->getBody()->willReturn($regionBody->reveal()); + + $requestCount = 0; + $httpHandler = function (RequestInterface $request) use ( + $awsTokenResponse, + $regionResponse, + &$requestCount + ): ResponseInterface { + $requestCount++; + switch ($requestCount) { + case 1: + return $awsTokenResponse->reveal(); + case 2: + return $regionResponse->reveal(); + } + throw new \Exception('Unexpected request'); + }; + + $subjectToken = $aws->fetchSubjectToken($httpHandler); + $unserializedToken = json_decode(urldecode($subjectToken), true); + $this->assertArrayHasKey('headers', $unserializedToken); + $this->assertArrayHasKey('method', $unserializedToken); + $this->assertArrayHasKey('url', $unserializedToken); + } + + public function testFetchSubjectTokenFromUrl() + { + $aws = new AwsNativeSource( + $this->audience, + $this->regionUrl, + $this->regionalCredVerificationUrl, + $this->securityCredentialsUrl, + $this->imdsv2SessionTokenUrl, + ); + + // Mock response from AWS Metadata Server + $awsTokenBody = $this->prophesize(StreamInterface::class); + $awsTokenBody->__toString()->willReturn('aws-token'); + $awsTokenResponse = $this->prophesize(ResponseInterface::class); + $awsTokenResponse->getBody()->willReturn($awsTokenBody->reveal()); + + // Mock response from Role Name request + $roleBody = $this->prophesize(StreamInterface::class); + $roleBody->__toString()->willReturn('test-role-name'); + $roleResponse = $this->prophesize(ResponseInterface::class); + $roleResponse->getBody()->willReturn($roleBody->reveal()); + + // Mock response from Security Credentials URL + $securityCredentialsBody = $this->prophesize(StreamInterface::class); + $securityCredentialsBody->__toString()->willReturn(json_encode([ + 'AccessKeyId' => 'test-access-key-id', + 'SecretAccessKey' => 'test-secret-access-key', + 'Token' => 'test-token', + ])); + $securityCredentialsResponse = $this->prophesize(ResponseInterface::class); + $securityCredentialsResponse->getBody()->willReturn($securityCredentialsBody->reveal()); + + // Mock response from Region URL + $regionBody = $this->prophesize(StreamInterface::class); + $regionBody->__toString()->willReturn('us-east-2b'); + $regionResponse = $this->prophesize(ResponseInterface::class); + $regionResponse->getBody()->willReturn($regionBody->reveal()); + + $requestCount = 0; + $httpHandler = function (RequestInterface $request) use ( + $awsTokenResponse, + $roleResponse, + $securityCredentialsResponse, + $regionResponse, + &$requestCount + ): ResponseInterface { + $requestCount++; + switch ($requestCount) { + case 1: + return $awsTokenResponse->reveal(); + case 2: + return $roleResponse->reveal(); + case 3: + return $securityCredentialsResponse->reveal(); + case 4: + return $regionResponse->reveal(); + } + throw new \Exception('Unexpected request'); + }; + + $subjectToken = $aws->fetchSubjectToken($httpHandler); + $unserializedToken = json_decode(urldecode($subjectToken), true); + $this->assertArrayHasKey('headers', $unserializedToken); + $this->assertArrayHasKey('method', $unserializedToken); + $this->assertArrayHasKey('url', $unserializedToken); + } +} diff --git a/Auth/tests/CredentialSource/ExecutableSourceTest.php b/Auth/tests/CredentialSource/ExecutableSourceTest.php new file mode 100644 index 000000000000..aa972f9b325e --- /dev/null +++ b/Auth/tests/CredentialSource/ExecutableSourceTest.php @@ -0,0 +1,330 @@ +expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'Pluggable Auth executables need to be explicitly allowed to run by setting the ' + . 'GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES environment Variable to 1.' + ); + + // Ensure env var does not equal 0 + putenv('GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES='); + $source = new ExecutableSource('some-command', null, null); + $source->fetchSubjectToken(); + } + + /** + * @dataProvider provideFetchSubjectToken + * @runInSeparateProcess + */ + public function testFetchSubjectToken(string $successToken) + { + putenv('GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES=1'); + + $cmd = 'fake-command'; + + $executableHandler = $this->prophesize(ExecutableHandler::class); + $executableHandler->__invoke($cmd) + ->shouldBeCalledOnce() + ->willReturn(0); + $executableHandler->getOutput() + ->shouldBeCalledOnce() + ->willReturn($successToken); + + $source = new ExecutableSource($cmd, null, $executableHandler->reveal()); + $subjectToken = $source->fetchSubjectToken(); + $this->assertEquals('abc', $subjectToken); + } + + public function provideFetchSubjectToken() + { + return [ + ['{"version": 1, "success": true, "token_type": "urn:ietf:params:oauth:token-type:id_token", "id_token": "abc"}'], + ['{"version": 1, "success": true, "token_type": "urn:ietf:params:oauth:token-type:jwt", "id_token": "abc"}'], + ['{"version": 1, "success": true, "token_type": "urn:ietf:params:oauth:token-type:saml2", "saml_response": "abc"}'] + ]; + } + + /** + * @dataProvider provideFetchSubjectTokenWithError + * @runInSeparateProcess + */ + public function testFetchSubjectTokenWithError( + int $returnCode, + string $output, + string $expectedExceptionMessage, + ?string $outputFile = null + ) { + $this->expectException(ExecutableResponseError::class); + $this->expectExceptionMessage($expectedExceptionMessage); + + putenv('GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES=1'); + + $cmd = 'fake-command'; + + $handler = $this->prophesize(ExecutableHandler::class); + $handler->__invoke($cmd) + ->shouldBeCalledOnce() + ->willReturn($returnCode); + $handler->getOutput() + ->shouldBeCalledOnce() + ->willReturn($output); + + $source = new ExecutableSource($cmd, $outputFile, $handler->reveal()); + $source->fetchSubjectToken(); + } + + public function provideFetchSubjectTokenWithError() + { + return [ + [1, '', 'The executable failed to run.'], + [1, 'error', 'The executable failed to run with the following error: error'], + [0, '{', 'The executable returned an invalid response: {'], + [0, '{}', 'Executable response must contain a "version" field'], + [0, '{"version": 1}', 'Executable response must contain a "success" field'], + [0, '{"version": 1, "success": false}', 'Executable response must contain a "code" field when unsuccessful'], + [0, '{"version": 1, "success": false, "code": 1}', 'Executable response must contain a "message" field when unsuccessful'], + [0, '{"version": 1, "success": false, "code": 1, "message": "error!"}', 'error!'], + [0, '{"version": 1, "success": true}', 'Executable response must contain a "token_type" field'], + [0, '{"version": 1, "success": true, "token_type": "wrong"}', 'Executable response "token_type" field must be one of'], + [ + 0, + '{"version": 1, "success": true, "token_type": "urn:ietf:params:oauth:token-type:saml2"}', + 'Executable response must contain a "saml_response" field when token_type=urn:ietf:params:oauth:token-type:saml2' + ], + [ + 0, + '{"version": 1, "success": true, "token_type": "urn:ietf:params:oauth:token-type:id_token"}', + 'Executable response must contain a "id_token" field when token_type=urn:ietf:params:oauth:token-type:id_token' + ], + [ + 0, + '{"version": 1, "success": true, "token_type": "urn:ietf:params:oauth:token-type:jwt"}', + 'Executable response must contain a "id_token" field when token_type=urn:ietf:params:oauth:token-type:jwt' + ], + [ + 0, + '{"version": 1, "success": true, "token_type": "urn:ietf:params:oauth:token-type:jwt", "id_token": "abc", "expiration_time": 1}', + 'Executable response is expired.', + ], + [ + 0, + '{"version": 1, "success": true, "token_type": "urn:ietf:params:oauth:token-type:jwt", "id_token": "abc"}', + 'The executable response must contain a "expiration_time" field for successful responses when an output_file has been specified in the configuration.', + '/some/output/file', + ], + ]; + } + + /** + * @dataProvider provideCachedTokenWithError + * @runInSeparateProcess + */ + public function testCachedTokenWithError( + string $cachedToken, + string $expectedExceptionMessage + ) { + $this->expectException(ExecutableResponseError::class); + $this->expectExceptionMessage($expectedExceptionMessage); + + putenv('GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES=1'); + + $outputFile = tempnam(sys_get_temp_dir(), 'token'); + file_put_contents($outputFile, $cachedToken); + + $cmd = 'fake-command'; + $handler = $this->prophesize(ExecutableHandler::class); + $handler->__invoke($cmd)->shouldNotBeCalled(); + $handler->getOutput()->shouldNotBeCalled(); + + $source = new ExecutableSource($cmd, $outputFile, $handler->reveal()); + $source->fetchSubjectToken(); + } + + public function provideCachedTokenWithError() + { + return [ + ['{', 'Error in output file: Error code INVALID_RESPONSE: The executable returned an invalid response: {'], + ['{}', 'Error in output file: Error code INVALID_EXECUTABLE_RESPONSE: Executable response must contain a "version" field'], + ['{"version": 1}', 'Error in output file: Error code INVALID_EXECUTABLE_RESPONSE: Executable response must contain a "success" field'], + ['{"version": 1, "success": false}', 'Error in output file: Error code INVALID_EXECUTABLE_RESPONSE: Executable response must contain a "code" field when unsuccessful'], + ['{"version": 1, "success": false, "code": 1}', 'Error in output file: Error code INVALID_EXECUTABLE_RESPONSE: Executable response must contain a "message" field when unsuccessful'], + ['{"version": 1, "success": true}', 'Error in output file: Error code INVALID_EXECUTABLE_RESPONSE: Executable response must contain a "token_type" field'], + ['{"version": 1, "success": true, "token_type": "wrong"}', 'Error in output file: Error code INVALID_EXECUTABLE_RESPONSE: Executable response "token_type" field must be one of'], + [ + '{"version": 1, "success": true, "token_type": "urn:ietf:params:oauth:token-type:saml2"}', + 'Error in output file: Error code INVALID_EXECUTABLE_RESPONSE: Executable response must contain a "saml_response" field when token_type=urn:ietf:params:oauth:token-type:saml2' + ], + [ + '{"version": 1, "success": true, "token_type": "urn:ietf:params:oauth:token-type:id_token"}', + 'Error in output file: Error code INVALID_EXECUTABLE_RESPONSE: Executable response must contain a "id_token" field when token_type=urn:ietf:params:oauth:token-type:id_token' + ], + [ + '{"version": 1, "success": true, "token_type": "urn:ietf:params:oauth:token-type:jwt"}', + 'Error in output file: Error code INVALID_EXECUTABLE_RESPONSE: Executable response must contain a "id_token" field when token_type=urn:ietf:params:oauth:token-type:jwt' + ], + [ + '{"version": 1, "success": true, "token_type": "urn:ietf:params:oauth:token-type:jwt", "id_token": "abc"}', + 'Error in output file: Error code INVALID_EXECUTABLE_RESPONSE: The executable response must contain a "expiration_time" field for successful responses when an output_file has been specified in the configuration.' + ], + ]; + } + + /** + * @runInSeparateProcess + */ + public function testCachedTokenFile() + { + putenv('GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES=1'); + + $outputFile = tempnam(sys_get_temp_dir(), 'token'); + file_put_contents($outputFile, json_encode([ + 'version' => 1, + 'success' => true, + 'token_type' => 'urn:ietf:params:oauth:token-type:id_token', + 'id_token' => 'abc', + 'expiration_time' => time() + 100, + ])); + + $source = new ExecutableSource('fake-command', $outputFile); + $subjectToken = $source->fetchSubjectToken(); + $this->assertEquals('abc', $subjectToken); + } + + /** + * @runInSeparateProcess + */ + public function testCachedTokenFileExpiredCallsExecutable() + { + putenv('GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES=1'); + + $cachedToken = [ + 'version' => 1, + 'success' => true, + 'token_type' => 'urn:ietf:params:oauth:token-type:id_token', + 'id_token' => 'abc', + // token is expired + 'expiration_time' => time() - 100, + ]; + $successToken = ['expiration_time' => time() + 100] + $cachedToken; + $outputFile = tempnam(sys_get_temp_dir(), 'token'); + file_put_contents($outputFile, json_encode($cachedToken)); + + $executableHandler = $this->prophesize(ExecutableHandler::class); + $executableHandler->__invoke('fake-command') + ->shouldBeCalledOnce() + ->willReturn(0); + $executableHandler->getOutput() + ->shouldBeCalledOnce() + ->willReturn(json_encode($successToken)); + + $source = new ExecutableSource('fake-command', $outputFile, $executableHandler->reveal()); + $subjectToken = $source->fetchSubjectToken(); + $this->assertEquals('abc', $subjectToken); + } + + /** + * @runInSeparateProcess + */ + public function testCachedTokenFileWithSuccessFalseCallsExecutable() + { + putenv('GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES=1'); + + $cachedToken = [ + 'version' => 1, + // token has success=false + 'success' => false, + 'code' => 0, + 'message' => 'error!' + ]; + $successToken = [ + 'version' => 1, + 'success' => true, + 'token_type' => 'urn:ietf:params:oauth:token-type:id_token', + 'id_token' => 'abc', + 'expiration_time' => time() + 100, + ]; + $outputFile = tempnam(sys_get_temp_dir(), 'token'); + file_put_contents($outputFile, json_encode($cachedToken)); + + $executableHandler = $this->prophesize(ExecutableHandler::class); + $executableHandler->__invoke('fake-command') + ->shouldBeCalledOnce() + ->willReturn(0); + $executableHandler->getOutput() + ->shouldBeCalledOnce() + ->willReturn(json_encode($successToken)); + + $source = new ExecutableSource('fake-command', $outputFile, $executableHandler->reveal()); + $subjectToken = $source->fetchSubjectToken(); + $this->assertEquals('abc', $subjectToken); + } + + /** + * @runInSeparateProcess + */ + public function testEmptyCachedTokenFileCallsExecutable() + { + putenv('GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES=1'); + + $successToken = [ + 'version' => 1, + 'success' => true, + 'token_type' => 'urn:ietf:params:oauth:token-type:id_token', + 'id_token' => 'abc', + 'expiration_time' => time() + 100, + ]; + $outputFile = tempnam(sys_get_temp_dir(), 'token'); + file_put_contents($outputFile, "\n"); + + $executableHandler = $this->prophesize(ExecutableHandler::class); + $executableHandler->__invoke('fake-command') + ->shouldBeCalledOnce() + ->willReturn(0); + $executableHandler->getOutput() + ->shouldBeCalledOnce() + ->willReturn(json_encode($successToken)); + + $source = new ExecutableSource('fake-command', $outputFile, $executableHandler->reveal()); + $subjectToken = $source->fetchSubjectToken(); + $this->assertEquals('abc', $subjectToken); + } +} diff --git a/Auth/tests/CredentialSource/FileSourceTest.php b/Auth/tests/CredentialSource/FileSourceTest.php new file mode 100644 index 000000000000..919efa739cc7 --- /dev/null +++ b/Auth/tests/CredentialSource/FileSourceTest.php @@ -0,0 +1,88 @@ +fetchSubjectToken(); + $this->assertEquals($expectedToken, $subjectToken); + } + + public function provideFetchSubjectToken() + { + $file1 = tempnam(sys_get_temp_dir(), 'test1'); + file_put_contents($file1, 'abc'); + + $file2 = tempnam(sys_get_temp_dir(), 'test2'); + file_put_contents($file2, json_encode(['token' => 'def'])); + + return [ + [$file1, 'abc'], + [$file2, 'def', 'json', 'token'] + ]; + } + + public function testFormatJsonWithNoSubjectTokenFieldNameThrowsException() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('subject_token_field_name must be set when format is JSON'); + + new FileSource('file', 'json'); + } + + public function testFormatJsonWithInvalidSubjectTokenFieldNameThrowsException() + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('subject_token_field_name not found in JSON file'); + + $file1 = tempnam(sys_get_temp_dir(), 'test'); + file_put_contents($file1, json_encode(['good_field_name' => 'abc'])); + + (new FileSource($file1, 'json', 'bad_field_name')) + ->fetchSubjectToken(); + } + + public function testFormatJsonWithInvalidJsonFileThrowsException() + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Unable to decode JSON file'); + + $file1 = tempnam(sys_get_temp_dir(), 'test'); + file_put_contents($file1, '{not-json}'); + + (new FileSource($file1, 'json', 'bad_field_name')) + ->fetchSubjectToken(); + } +} diff --git a/Auth/tests/CredentialSource/UrlSourceTest.php b/Auth/tests/CredentialSource/UrlSourceTest.php new file mode 100644 index 000000000000..84c04f3f649d --- /dev/null +++ b/Auth/tests/CredentialSource/UrlSourceTest.php @@ -0,0 +1,144 @@ +assertEquals('GET', $request->getMethod()); + $this->assertEquals('test.url', (string) $request->getUri()); + + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn($responseBody); + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + + return $response->reveal(); + }; + + $source = new UrlSource('test.url', $format, $subjectTokenFieldName); + $subjectToken = $source->fetchSubjectToken($handler); + $this->assertEquals($expectedToken, $subjectToken); + } + + public function provideFetchSubjectToken() + { + return [ + ['abc', 'abc', null], + [json_encode(['token' => 'def']), 'def', 'json', 'token'] + ]; + } + + public function testHeaders() + { + $handler = function (RequestInterface $request): ResponseInterface { + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals('test.url', (string) $request->getUri()); + $this->assertEquals('abc', (string) $request->getHeaderLine('custom-header-1')); + $this->assertEquals('def', (string) $request->getHeaderLine('custom-header-2')); + + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn('xyz'); + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body); + + return $response->reveal(); + }; + + $headers = [ + 'custom-header-1' => 'abc', + 'custom-header-2' => 'def', + ]; + + $source = new UrlSource('test.url', null, null, $headers); + $subjectToken = $source->fetchSubjectToken($handler); + $this->assertEquals('xyz', $subjectToken); + } + + public function testFormatJsonWithNoSubjectTokenFieldNameThrowsException() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('subject_token_field_name must be set when format is JSON'); + + new UrlSource('test.url', 'json'); + } + + public function testFormatJsonWithInvalidSubjectTokenFieldNameThrowsException() + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('subject_token_field_name not found in JSON file'); + + $handler = function (RequestInterface $request): ResponseInterface { + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals('test.url', (string) $request->getUri()); + + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn(json_encode(['good_field_name' => 'abc'])); + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + + return $response->reveal(); + }; + + (new UrlSource('test.url', 'json', 'bad_field_name')) + ->fetchSubjectToken($handler); + } + + public function testFormatJsonWithInvalidJsonResponseThrowsException() + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Unable to decode JSON response'); + + $handler = function (RequestInterface $request): ResponseInterface { + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals('test.url', (string) $request->getUri()); + + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn('{not-json}'); + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + + return $response->reveal(); + }; + + (new UrlSource('test.url', 'json', 'bad_field_name')) + ->fetchSubjectToken($handler); + } +} diff --git a/Auth/tests/Credentials/AppIdentityCredentialsTest.php b/Auth/tests/Credentials/AppIdentityCredentialsTest.php new file mode 100644 index 000000000000..0811bade0f48 --- /dev/null +++ b/Auth/tests/Credentials/AppIdentityCredentialsTest.php @@ -0,0 +1,237 @@ +assertFalse(AppIdentityCredentials::onAppEngine()); + } + + /** + * @runInSeparateProcess + */ + public function testOnAppEngineIsTrueWhenServerSoftwareIsGoogleAppEngine() + { + $this->imitateInAppEngine(); + $this->assertTrue(AppIdentityCredentials::onAppEngine()); + } + + /** + * @runInSeparateProcess + */ + public function testOnAppEngineIsTrueWhenAppEngineRuntimeIsPhp() + { + $this->imitateInAppEngine(); + $this->assertTrue(AppIdentityCredentials::onAppEngine()); + } + + /** + * @runInSeparateProcess + */ + public function testOnAppEngineIsTrueInDevelopmentServer() + { + $_SERVER['APPENGINE_RUNTIME'] = 'php'; + $this->assertTrue(AppIdentityCredentials::onAppEngine()); + } + + public function testGetCacheKeyShouldBeEmpty() + { + $g = new AppIdentityCredentials(); + $this->assertEmpty($g->getCacheKey()); + } + + public function testFetchAuthTokenShouldBeEmptyIfNotOnAppEngine() + { + $g = new AppIdentityCredentials(); + $this->assertEquals([], $g->fetchAuthToken()); + } + + /** + * @runInSeparateProcess + */ + public function testFetchAuthTokenReturnsExpectedToken() + { + $this->imitateInAppEngine(); + + $wantedToken = [ + 'access_token' => '1/abdef1234567890', + 'expires_in' => '57', + 'token_type' => 'Bearer', + ]; + + AppIdentityService::$accessToken = $wantedToken; + + $g = new AppIdentityCredentials(); + $this->assertEquals($wantedToken, $g->fetchAuthToken()); + } + + /** + * @runInSeparateProcess + */ + public function testScopeIsAlwaysArray() + { + $this->imitateInAppEngine(); + + $scope1 = ['scopeA', 'scopeB']; + $scope2 = 'scopeA scopeB'; + $scope3 = 'scopeA'; + + $g = new AppIdentityCredentials($scope1); + $g->fetchAuthToken(); + $this->assertEquals($scope1, AppIdentityService::$scope); + + $g = new AppIdentityCredentials($scope2); + $g->fetchAuthToken(); + $this->assertEquals(explode(' ', $scope2), AppIdentityService::$scope); + + $g = new AppIdentityCredentials($scope3); + $g->fetchAuthToken(); + $this->assertEquals([$scope3], AppIdentityService::$scope); + } + + /** + * @dataProvider appEngineRequired + */ + public function testMethodsFailWhenNotInAppEngine($method, $args = [], $expected = null) + { + if ($expected === null) { + $this->expectException(\Exception::class); + } + + $creds = new AppIdentityCredentials(); + $res = call_user_func_array([$creds, $method], $args); + + if ($expected !== null) { + $this->assertEquals($expected, $res); + } + } + + public function appEngineRequired() + { + return [ + ['fetchAuthToken', [], []], + ['signBlob', ['foo']], + ['getClientName'] + ]; + } + + /** + * @runInSeparateProcess + */ + public function testSignBlob() + { + $this->imitateInAppEngine(); + + $creds = new AppIdentityCredentials(); + $string = 'test'; + $res = $creds->signBlob($string); + + $this->assertEquals(base64_encode('Signed: ' . $string), $res); + } + + /** + * @runInSeparateProcess + */ + public function testGetClientName() + { + $this->imitateInAppEngine(); + + $creds = new AppIdentityCredentials(); + + $expected = 'foobar'; + AppIdentityService::$serviceAccountName = $expected; + + $this->assertEquals($expected, $creds->getClientName()); + + AppIdentityService::$serviceAccountName = 'notreturned'; + $this->assertEquals($expected, $creds->getClientName()); + } + + public function testGetLastReceivedTokenNullByDefault() + { + $creds = new AppIdentityCredentials(); + $this->assertNull($creds->getLastReceivedToken()); + } + + /** + * @runInSeparateProcess + */ + public function testGetLastReceviedTokenCaches() + { + $this->imitateInAppEngine(); + + $creds = new AppIdentityCredentials(); + + $wantedToken = [ + 'access_token' => '1/abdef1234567890', + 'expires_in' => '57', + 'expiration_time' => time() + 57, + 'token_type' => 'Bearer', + ]; + + AppIdentityService::$accessToken = $wantedToken; + + $creds->fetchAuthToken(); + + $this->assertEquals([ + 'access_token' => $wantedToken['access_token'], + 'expires_at' => $wantedToken['expiration_time'] + ], $creds->getLastReceivedToken()); + } + + /** + * @runInSeparateProcess + */ + public function testGetProjectId() + { + $this->imitateInAppEngine(); + + $projectId = 'foobar'; + AppIdentityService::$applicationId = $projectId; + $this->assertEquals($projectId, (new AppIdentityCredentials())->getProjectId()); + } + + public function testGetProjectOutsideAppEngine() + { + $this->assertNull((new AppIdentityCredentials())->getProjectId()); + } + + private function imitateInAppEngine() + { + // include the mock AppIdentityService class + require_once __DIR__ . '/../mocks/AppIdentityService.php'; + $_SERVER['SERVER_SOFTWARE'] = 'Google App Engine'; + // $_SERVER['APPENGINE_RUNTIME'] = 'php'; + } +} diff --git a/Auth/tests/Credentials/ExternalAccountAuthorizedUserCredentialsTest.php b/Auth/tests/Credentials/ExternalAccountAuthorizedUserCredentialsTest.php new file mode 100644 index 000000000000..6b69518029c1 --- /dev/null +++ b/Auth/tests/Credentials/ExternalAccountAuthorizedUserCredentialsTest.php @@ -0,0 +1,189 @@ + 'client-id', + 'client_secret' => 'client-secret', + 'refresh_token' => 'refresh-token', + 'token_url' => 'http://token-url.com', + ]; + + public function testValidConstructor() + { + $creds = new ExternalAccountAuthorizedUserCredentials('scope', $this->baseJsonKey); + $this->assertInstanceOf(ExternalAccountAuthorizedUserCredentials::class, $creds); + } + + /** + * @dataProvider provideInvalidJson + */ + public function testInvalidConstructorThrowsException(array $jsonKey, string $expectedMessage) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($expectedMessage); + new ExternalAccountAuthorizedUserCredentials('scope', $jsonKey); + } + + public function provideInvalidJson() + { + return [ + [ + [], + 'json key is missing the client_id field' + ], + [ + ['client_id' => 'id'], + 'json key is missing the client_secret field' + ], + [ + ['client_id' => 'id', 'client_secret' => 'secret'], + 'json key is missing the refresh_token field' + ], + [ + ['client_id' => 'id', 'client_secret' => 'secret', 'refresh_token' => 'token'], + 'json key is missing the token_url field' + ], + ]; + } + + public function testFetchAuthToken() + { + $scope = 'myscope'; + $creds = new ExternalAccountAuthorizedUserCredentials($scope, $this->baseJsonKey); + $credsReflection = new \ReflectionClass(ExternalAccountAuthorizedUserCredentials::class); + $authProp = $credsReflection->getProperty('auth'); + $authProp->setAccessible(true); + $oauth2 = $authProp->getValue($creds); + + $expectedAuthToken = ['access_token' => 'new_access_token']; + $mockHttpHandler = function (RequestInterface $request) use ($expectedAuthToken) { + $this->assertEquals( + 'Basic ' . base64_encode('client-id:client-secret'), + $request->getHeaderLine('Authorization') + ); + $metricHeader = $request->getHeaderLine('x-goog-api-client'); + $this->assertStringContainsString('gl-php/', $metricHeader); + $this->assertStringContainsString('auth/', $metricHeader); + $this->assertStringContainsString('cred-type/eaau', $metricHeader); + $this->assertStringContainsString('auth-request-type/at', $metricHeader); + return new \GuzzleHttp\Psr7\Response(200, [], json_encode($expectedAuthToken)); + }; + + $authToken = $creds->fetchAuthToken($mockHttpHandler); + $this->assertEquals($expectedAuthToken, $authToken); + } + + public function testGetCacheKey() + { + $scope = 'myscope'; + $creds = new ExternalAccountAuthorizedUserCredentials($scope, $this->baseJsonKey); + $expectedKey = hash('sha256', implode('.', [ + $this->baseJsonKey['client_id'], + $scope, + $this->baseJsonKey['refresh_token'] + ])); + $this->assertEquals($expectedKey, $creds->getCacheKey()); + } + + public function testGetCacheKeyWithDifferentRefreshTokensIsUnique() + { + $scope = 'myscope'; + $jsonKey1 = $this->baseJsonKey; + $jsonKey2 = ['refresh_token' => 'different-refresh-token'] + $this->baseJsonKey; + + $creds1 = new ExternalAccountAuthorizedUserCredentials($scope, $jsonKey1); + $creds2 = new ExternalAccountAuthorizedUserCredentials($scope, $jsonKey2); + + $this->assertNotEquals($creds1->getCacheKey(), $creds2->getCacheKey()); + } + + public function testGetUniverseDomain() + { + $jsonKey = ['universe_domain' => 'my-universe.com'] + $this->baseJsonKey; + $creds = new ExternalAccountAuthorizedUserCredentials('scope', $jsonKey); + $this->assertEquals('my-universe.com', $creds->getUniverseDomain()); + } + + public function testGetUniverseDomainDefault() + { + $creds = new ExternalAccountAuthorizedUserCredentials('scope', $this->baseJsonKey); + $this->assertEquals('googleapis.com', $creds->getUniverseDomain()); + } + + public function testGetLastReceivedToken() + { + $creds = new ExternalAccountAuthorizedUserCredentials('scope', $this->baseJsonKey); + $credsReflection = new \ReflectionClass(ExternalAccountAuthorizedUserCredentials::class); + $authProp = $credsReflection->getProperty('auth'); + $authProp->setAccessible(true); + $oauth2 = $authProp->getValue($creds); + + $token = [ + 'access_token' => 'my_token', + 'expires_in' => 3600, + 'token_type' => 'Bearer', + ]; + $oauth2->updateToken($token); + + $lastToken = $creds->getLastReceivedToken(); + $this->assertEquals($token['access_token'], $lastToken['access_token']); + $this->assertEquals($token['expires_in'], $lastToken['expires_in']); + } + + public function testGetQuotaProject() + { + $jsonKey = ['quota_project_id' => 'my-quota-project'] + $this->baseJsonKey; + $creds = new ExternalAccountAuthorizedUserCredentials('scope', $jsonKey); + $this->assertEquals('my-quota-project', $creds->getQuotaProject()); + } + + public function testGetQuotaProjectNotSet() + { + $creds = new ExternalAccountAuthorizedUserCredentials('scope', $this->baseJsonKey); + $this->assertNull($creds->getQuotaProject()); + } + + public function testGetGrantedScope() + { + $creds = new ExternalAccountAuthorizedUserCredentials('scope', $this->baseJsonKey); + $credsReflection = new \ReflectionClass(ExternalAccountAuthorizedUserCredentials::class); + $authProp = $credsReflection->getProperty('auth'); + $authProp->setAccessible(true); + $oauth2 = $authProp->getValue($creds); + $oauth2->setGrantedScope('granted_scope'); + $this->assertEquals('granted_scope', $creds->getGrantedScope()); + } +} diff --git a/Auth/tests/Credentials/ExternalAccountCredentialsTest.php b/Auth/tests/Credentials/ExternalAccountCredentialsTest.php new file mode 100644 index 000000000000..a3a540fae557 --- /dev/null +++ b/Auth/tests/Credentials/ExternalAccountCredentialsTest.php @@ -0,0 +1,755 @@ + 'external_account', + 'token_url' => 'token-url.com', + 'audience' => '', + 'subject_token_type' => '', + 'credential_source' => ['url' => 'sts-url.com'], + ]; + + /** + * @dataProvider provideCredentialSourceFromCredentials + */ + public function testCredentialSourceFromCredentials( + array $credentialSource, + string $expectedSourceClass, + array $expectedProperties = [] + ) { + $jsonCreds = [ + 'credential_source' => $credentialSource, + ] + $this->baseCreds; + + $credsReflection = new \ReflectionClass(ExternalAccountCredentials::class); + $credsProp = $credsReflection->getProperty('auth'); + + $creds = new ExternalAccountCredentials('a-scope', $jsonCreds); + $oauth = $credsProp->getValue($creds); + + $oauthReflection = new \ReflectionClass(OAuth2::class); + $oauthProp = $oauthReflection->getProperty('subjectTokenFetcher'); + $subjectTokenFetcher = $oauthProp->getValue($oauth); + + $this->assertInstanceOf($expectedSourceClass, $subjectTokenFetcher); + + $sourceReflection = new \ReflectionClass($subjectTokenFetcher); + foreach ($expectedProperties as $propName => $expectedPropValue) { + $sourceProp = $sourceReflection->getProperty($propName); + $this->assertEquals($expectedPropValue, $sourceProp->getValue($subjectTokenFetcher)); + } + } + + public function provideCredentialSourceFromCredentials() + { + return [ + [ + [ + 'environment_id' => 'aws1', + 'regional_cred_verification_url' => 'abc', + 'region_url' => 'def', + 'url' => 'ghi', + 'imdsv2_session_token_url' => 'jkl' + ], + AwsNativeSource::class, + [ + 'regionalCredVerificationUrl' => 'abc', + 'regionUrl' => 'def', + 'securityCredentialsUrl' => 'ghi', + 'imdsv2SessionTokenUrl' => 'jkl', + ], + ], + [ + ['file' => + 'path/to/credsfile.json', + 'format' => [ + 'type' => 'json', + 'subject_token_field_name' => 'token' + ] + ], + FileSource::class, + [ + 'format' => 'json', + 'subjectTokenFieldName' => 'token', + ] + ], + [ + ['url' => 'https://test.com'], + UrlSource::class + ], + [ + ['url' => 'https://test.com', 'format' => ['type' => 'json', 'subject_token_field_name' => 'token']], + UrlSource::class + ], + [ + [ + 'url' => 'https://test.com', + 'format' => [ + 'type' => 'json', + 'subject_token_field_name' => 'token', + ], + 'headers' => ['foo' => 'bar'], + ], + UrlSource::class, + [ + 'format' => 'json', + 'subjectTokenFieldName' => 'token', + 'headers' => ['foo' => 'bar'], + ] + ], + ]; + } + + /** + * @dataProvider provideInvalidCredentialsJson + */ + public function testInvalidCredentialsJsonThrowsException(array $json, string $exceptionMessage) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($exceptionMessage); + + new ExternalAccountCredentials('a-scope', $json); + } + + public function provideInvalidCredentialsJson() + { + return [ + [ + [], + 'json key is missing the type field' + ], + [ + ['type' => 'foo'], + 'expected "external_account" type but received "foo"' + ], + [ + ['type' => 'external_account'], + 'json key is missing the token_url field' + ], + [ + ['type' => 'external_account', 'token_url' => ''], + 'json key is missing the audience field' + ], + [ + ['type' => 'external_account', 'token_url' => '', 'audience' => ''], + 'json key is missing the subject_token_type field' + ], + [ + ['type' => 'external_account', 'token_url' => '', 'audience' => '', 'subject_token_type' => ''], + 'json key is missing the credential_source field' + ], + [ + [ + 'type' => 'external_account', + 'token_url' => '', + 'audience' => '', + 'subject_token_type' => '', + 'credential_source' => [] + ], + 'Unable to determine credential source from json key' + ], + [ + [ + 'type' => 'external_account', + 'token_url' => '', + 'audience' => '', + 'subject_token_type' => '', + 'credential_source' => [ + 'environment_id' => 'aws2', + ]], + 'aws version "2" is not supported in the current build.' + ], + [ + [ + 'type' => 'external_account', + 'token_url' => '', + 'audience' => '', + 'subject_token_type' => '', + 'credential_source' => [ + 'environment_id' => 'aws1', + ]], + 'The regional_cred_verification_url field is required for aws1 credential source.' + ], + [ + [ + 'type' => 'external_account', + 'token_url' => '', + 'audience' => '', + 'subject_token_type' => '', + 'credential_source' => [ + 'environment_id' => 'aws1', + 'region_url' => '', + ]], + 'The regional_cred_verification_url field is required for aws1 credential source.' + ], + ]; + } + + public function testFetchAuthTokenFileCredentials() + { + $tmpFile = tempnam(sys_get_temp_dir(), 'test'); + file_put_contents($tmpFile, 'abc'); + + $jsonCreds = [ + 'credential_source' => ['file' => $tmpFile], + ] + $this->baseCreds; + + $creds = new ExternalAccountCredentials('a-scope', $jsonCreds); + + $httpHandler = function (RequestInterface $request) { + $this->assertEquals('token-url.com', (string) $request->getUri()); + parse_str((string) $request->getBody(), $requestBody); + $this->assertEquals('abc', $requestBody['subject_token']); + + $responseBody = $this->prophesize(StreamInterface::class); + $responseBody->__toString()->willReturn(json_encode(['access_token' => 'def', 'expires_in' => 1000])); + + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($responseBody->reveal()); + $response->hasHeader('Content-Type')->willReturn(false); + + return $response->reveal(); + }; + + $authToken = $creds->fetchAuthToken($httpHandler); + $this->assertArrayHasKey('access_token', $authToken); + $this->assertEquals('def', $authToken['access_token']); + } + + public function testFetchAuthTokenUrlCredentials() + { + $creds = new ExternalAccountCredentials('a-scope', $this->baseCreds); + + $requestCount = 0; + $httpHandler = function (RequestInterface $request) use (&$requestCount) { + switch (++$requestCount) { + case 1: + $this->assertEquals('sts-url.com', (string) $request->getUri()); + $responseBody = 'abc'; + break; + + case 2: + $this->assertEquals('token-url.com', (string) $request->getUri()); + parse_str((string) $request->getBody(), $requestBody); + $this->assertEquals('abc', $requestBody['subject_token']); + $responseBody = '{"access_token": "def"}'; + break; + } + + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn($responseBody); + + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + if ($requestCount === 2) { + $response->hasHeader('Content-Type')->willReturn(false); + } + + return $response->reveal(); + }; + + $authToken = $creds->fetchAuthToken($httpHandler); + $this->assertArrayHasKey('access_token', $authToken); + $this->assertEquals('def', $authToken['access_token']); + } + + public function testFetchAuthTokenWithImpersonation() + { + $tmpFile = tempnam(sys_get_temp_dir(), 'test'); + file_put_contents($tmpFile, 'abc'); + + $jsonCreds = [ + 'credential_source' => ['file' => $tmpFile], + 'service_account_impersonation_url' => 'service-account-impersonation-url.com', + ] + $this->baseCreds; + + $creds = new ExternalAccountCredentials('a-scope', $jsonCreds); + + $requestCount = 0; + $expiry = '2023-10-05T18:00:01Z'; + $httpHandler = function (RequestInterface $request) use (&$requestCount, $expiry) { + switch (++$requestCount) { + case 1: + $this->assertEquals('token-url.com', (string) $request->getUri()); + parse_str((string) $request->getBody(), $requestBody); + $this->assertEquals('abc', $requestBody['subject_token']); + $responseBody = '{"access_token": "def"}'; + break; + case 2: + $this->assertEquals('service-account-impersonation-url.com', (string) $request->getUri()); + $requestBody = json_decode((string) $request->getBody(), true); + $this->assertEquals(['a-scope'], $requestBody['scope']); + $responseBody = json_encode(['accessToken' => 'ghi', 'expireTime' => $expiry]); + break; + } + + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn($responseBody); + + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + if ($requestCount === 1) { + $response->hasHeader('Content-Type')->willReturn(false); + } + + return $response->reveal(); + }; + + $authToken = $creds->fetchAuthToken($httpHandler); + $this->assertArrayHasKey('access_token', $authToken); + $this->assertEquals('ghi', $authToken['access_token']); + $this->assertEquals(strtotime($expiry), $authToken['expires_at']); + + // test that getLastReceivedToken() returns the correct token + $this->assertEquals($authToken, $creds->getLastReceivedToken()); + } + + public function testGetQuotaProject() + { + $jsonCreds = [ + + 'quota_project_id' => 'test_quota_project', + ] + $this->baseCreds; + + $creds = new ExternalAccountCredentials('a-scope', $jsonCreds); + $this->assertEquals('test_quota_project', $creds->getQuotaProject()); + } + + /** + * Test the getProjectId method, which makes an API call using the project number in order to + * retrieve the project ID. + * + * @dataProvider provideGetProjectId + */ + public function testGetProjectId(array $jsonCreds, string $expectedProjectNumber) + { + $requestCount = 0; + $httpHandler = function (RequestInterface $request) use (&$requestCount, $expectedProjectNumber) { + switch (++$requestCount) { + case 1: + $this->assertEquals('sts-url.com', (string) $request->getUri()); + $responseBody = 'abc'; + break; + case 2: + $this->assertEquals('token-url.com', (string) $request->getUri()); + $responseBody = '{"access_token": "def"}'; + break; + case 3: + $this->assertEquals( + 'https://cloudresourcemanager.googleapis.com/v1/projects/' . $expectedProjectNumber, + (string) $request->getUri() + ); + $responseBody = json_encode(['projectId' => 'test-project-id']); + break; + } + + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn($responseBody); + + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + $response->hasHeader('Content-Type')->willReturn(false); + + return $response->reveal(); + }; + + $creds = new ExternalAccountCredentials('a-scope', $jsonCreds); + $this->assertEquals('test-project-id', $creds->getProjectId($httpHandler)); + } + + public function provideGetProjectId() + { + return [ + // from audience + [ + [ + 'audience' => '//iam.googleapis.com/projects/1234/locations/global/' + . 'workloadIdentityPools/foo/providers/bar', + ] + $this->baseCreds, + '1234' + ], + // from workforce_pool_user_project + [ + [ + 'audience' => '//iam.googleapis.com/locations/global/workforcePools/foo/providers/bar', + 'workforce_pool_user_project' => '4567', + ] + $this->baseCreds, + '4567' + ], + ]; + } + + /** + * the getProjectId method makes an API call using the project number in order to retrieve the + * project ID. Test that a cached access token is used for the API call to fetch the projectId, + * instead of retrieving a new one. + */ + public function testCacheIsCalledForGetProjectIdWithCache() + { + $jsonCreds = [ + 'audience' => '//iam.googleapis.com/projects/1234/locations/global/workloadIdentityPools/foo/providers/bar', + ] + $this->baseCreds; + + $httpHandler = function (RequestInterface $request) { + $this->assertEquals( + 'https://cloudresourcemanager.googleapis.com/v1/projects/1234', + (string) $request->getUri() + ); + $this->assertEquals('Bearer some-token', $request->getHeaderLine('authorization')); + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn(json_encode(['projectId' => 'test-project-id'])); + + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + $response->hasHeader('Content-Type')->willReturn(false); + + return $response->reveal(); + }; + + $mockCacheItem = $this->prophesize('Psr\Cache\CacheItemInterface'); + $mockCacheItem->isHit() + ->shouldBeCalledTimes(1) + ->willReturn(true); + $mockCacheItem->get() + ->shouldBeCalledTimes(1) + ->willReturn(['access_token' => 'some-token']); + $mockCache = $this->prophesize('Psr\Cache\CacheItemPoolInterface'); + $mockCache->getItem(Argument::any()) + ->shouldBeCalledTimes(1) + ->willReturn($mockCacheItem->reveal()); + + // Run the test + $creds = new ExternalAccountCredentials('a-scope', $jsonCreds); + + // Verify the cache passed to the wrapping Fetcher is never called + $cachedFetcher = new FetchAuthTokenCache( + $creds, + [], + $mockCache->reveal() + ); + + $this->assertEquals('test-project-id', $cachedFetcher->getProjectId($httpHandler)); + } + + public function testGetUniverseDomain() + { + // no universe domain is the default "googleapis.com" + $creds = new ExternalAccountCredentials('a-scope', $this->baseCreds); + $this->assertEquals( + GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN, + $creds->getUniverseDomain() + ); + + // universe domain in credentials is used if supplied + $universeDomain = 'example-universe.com'; + $jsonCreds = [ + 'universe_domain' => $universeDomain, + ] + $this->baseCreds; + + $creds = new ExternalAccountCredentials('a-scope', $jsonCreds); + $this->assertEquals($universeDomain, $creds->getUniverseDomain()); + } + + public function testWorkforcePoolWithNonWorkforceAudienceThrowsException() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'workforce_pool_user_project should not be set for non-workforce pool credentials.' + ); + + $jsonCreds = [ + 'audience' => '//iam.googleapis.com/projects/1234/locations/global/workloadIdentityPools/foo/providers/bar', + 'workforce_pool_user_project' => '4567', + ] + $this->baseCreds; + new ExternalAccountCredentials('a-scope', $jsonCreds); + } + + public function testFetchAuthTokenWithWorkforcePoolCredentials() + { + $tmpFile = tempnam(sys_get_temp_dir(), 'test'); + file_put_contents($tmpFile, 'abc'); + + $jsonCreds = [ + 'credential_source' => ['file' => $tmpFile], + 'audience' => '//iam.googleapis.com/locations/global/workforcePools/foo/providers/bar', + 'workforce_pool_user_project' => '4567', + 'service_account_impersonation_url' => 'service-account-impersonation-url.com', + ] + $this->baseCreds; + + $creds = new ExternalAccountCredentials('a-scope', $jsonCreds); + + $requestCount = 0; + $expiry = '2023-10-05T18:00:01Z'; + $httpHandler = function (RequestInterface $request) use (&$requestCount, $expiry) { + switch (++$requestCount) { + case 1: + $this->assertEquals('token-url.com', (string) $request->getUri()); + parse_str((string) $request->getBody(), $requestBody); + $this->assertEquals('abc', $requestBody['subject_token']); + $this->assertEquals('{"userProject":"4567"}', $requestBody['options']); + $responseBody = '{"access_token": "def"}'; + break; + case 2: + $this->assertEquals('service-account-impersonation-url.com', (string) $request->getUri()); + $responseBody = json_encode(['accessToken' => 'def', 'expireTime' => $expiry]); + break; + } + + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn($responseBody); + + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + if ($requestCount === 1) { + $response->hasHeader('Content-Type')->willReturn(false); + } + + return $response->reveal(); + }; + + $authToken = $creds->fetchAuthToken($httpHandler); + $this->assertArrayHasKey('access_token', $authToken); + $this->assertEquals('def', $authToken['access_token']); + $this->assertEquals(strtotime($expiry), $authToken['expires_at']); + } + + public function testFileSourceCacheKey() + { + $this->baseCreds['credential_source'] = ['file' => 'fakeFile']; + $credentials = new ExternalAccountCredentials('scope1', $this->baseCreds); + $cacheKey = $credentials->getCacheKey(); + $expectedKey = 'fakeFile.scope1...'; + $this->assertEquals($expectedKey, $cacheKey); + } + + public function testAWSSourceCacheKey() + { + $this->baseCreds['credential_source'] = [ + 'environment_id' => 'aws1', + 'regional_cred_verification_url' => 'us-east', + 'region_url' => 'aws.us-east.com', + 'url' => 'aws.us-east.token.com', + 'imdsv2_session_token_url' => '12345' + ]; + $this->baseCreds['audience'] = 'audience1'; + $credentials = new ExternalAccountCredentials('scope1', $this->baseCreds); + $cacheKey = $credentials->getCacheKey(); + $expectedKey = '12345.aws.us-east.token.com.aws.us-east.com.us-east.audience1...'; + $this->assertEquals($expectedKey, $cacheKey); + } + + public function testUrlSourceCacheKey() + { + $this->baseCreds['credential_source'] = [ + 'url' => 'fakeUrl', + 'format' => [ + 'type' => 'json', + 'subject_token_field_name' => 'keyShouldBeHere' + ] + ]; + + $credentials = new ExternalAccountCredentials('scope1', $this->baseCreds); + $cacheKey = $credentials->getCacheKey(); + $expectedKey = 'fakeUrl.scope1...'; + $this->assertEquals($expectedKey, $cacheKey); + } + + public function testExecutableSourceCacheKey() + { + $this->baseCreds['credential_source'] = [ + 'executable' => [ + 'command' => 'ls -al', + 'output_file' => './output.txt' + ] + ]; + + $credentials = new ExternalAccountCredentials('scope1', $this->baseCreds); + $cacheKey = $credentials->getCacheKey(); + + $expectedCacheKey = 'ls -al../output.txt.scope1...'; + $this->assertEquals($cacheKey, $expectedCacheKey); + } + + /** + * @runInSeparateProcess + */ + public function testExecutableCredentialSourceEnvironmentVars() + { + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('This test does not work on Windows'); + } + + putenv('GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES=1'); + + $tmpFile = tempnam(sys_get_temp_dir(), 'test'); + $outputFile = tempnam(sys_get_temp_dir(), 'output'); + $fileContents = 'foo-' . rand(); + $successJson = json_encode([ + 'version' => 1, + 'success' => true, + 'token_type' => 'urn:ietf:params:oauth:token-type:id_token', + 'id_token' => 'abc', + 'expiration_time' => time() + 100, + ]); + + $command = sprintf( + 'echo $GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE,$GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE,%s > %s' . + ' && echo \'%s\' > $GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE' . + ' && echo \'%s\'', + $fileContents, + $tmpFile, + $successJson, + $successJson + ); + + $json = [ + 'audience' => 'test-audience', + 'subject_token_type' => 'test-token-type', + 'credential_source' => [ + 'executable' => [ + 'command' => $command, + 'timeout_millis' => 5000, + 'output_file' => $outputFile, + ], + ], + ] + $this->baseCreds; + + $creds = new ExternalAccountCredentials('a-scope', $json); + $authToken = $creds->fetchAuthToken(function (RequestInterface $request) { + parse_str((string) $request->getBody(), $requestBody); + $this->assertEquals('abc', $requestBody['subject_token']); + + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn('{"access_token": "def"}'); + + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + + $response->hasHeader('Content-Type')->willReturn(false); + + return $response->reveal(); + }); + + $this->assertArrayHasKey('access_token', $authToken); + $this->assertEquals('def', $authToken['access_token']); + + $this->assertFileExists($tmpFile); + $this->assertEquals( + 'test-audience,test-token-type,' . $fileContents . PHP_EOL, + file_get_contents($tmpFile) + ); + } + + public function testUpdateMetadataWithRegionalAccessBoundary() + { + $httpHandler = $this->getHandler([ + new Response(200, [], '{"access_token": "source-token", "expires_in": 3600}'), + new Response(200, [], '{"locations": [], "encodedLocations": "foo"}'), + ]); + $dir = sys_get_temp_dir(); + $tokenFile = tempnam($dir, 'token'); + + $jsonKey = [ + 'type' => 'external_account', + 'private_key' => file_get_contents(__DIR__ . '/../fixtures/fixtures1/private.pem'), + 'client_email' => 'test@example.com', + 'audience' => '//iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/' + . 'workloadIdentityPools/POOL_ID/providers/PROJECT_ID', + 'subject_token_type' => 'urn:ietf:params:oauth:token-type:jwt', + 'token_url' => 'https://sts.googleapis.com/v1/token', + 'credential_source' => ['file' => $tokenFile] + ]; + $serviceAccountCreds = new ExternalAccountCredentials( + 'a-scope', + $jsonKey, + enableRegionalAccessBoundary: true + ); + + $metadata = $serviceAccountCreds->updateMetadata([], null, $httpHandler); + + $this->assertArrayHasKey('x-allowed-locations', $metadata); + $this->assertEquals('foo', $metadata['x-allowed-locations']); + } + + public function testRegionalAccessBoundaryWithImpersonationUsesServiceAccountEmail() + { + $count = 0; + $httpHandler = function ($request) use (&$count) { + if ($count === 2) { + $this->assertStringContainsString('test@example', $request->getUri()->getPath()); + } + return match ($count++) { + 0 => new Response(200, [], '{"access_token": "source-token", "expires_in": 3600}'), + 1 => new Response(200, [], '{"accessToken": "access-token", "expireTime": 1}'), + 2 => new Response(200, [], '{"locations": [], "encodedLocations": "foo"}'), + }; + }; + $dir = sys_get_temp_dir(); + $tokenFile = tempnam($dir, 'token'); + + $jsonKey = [ + 'type' => 'external_account', + 'client_email' => 'test@example.com', + 'audience' => '//iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/' + . 'workloadIdentityPools/POOL_ID/providers/PROJECT_ID', + 'subject_token_type' => 'urn:ietf:params:oauth:token-type:jwt', + 'token_url' => 'https://sts.googleapis.com/v1/token', + 'credential_source' => ['file' => $tokenFile], + 'service_account_impersonation_url' => 'https://iamcredentials.googleapis.com/v1/' + . 'projects/-/serviceAccounts/test@example.com:generateAccessToken', + ]; + $serviceAccountCreds = new ExternalAccountCredentials( + 'a-scope', + $jsonKey, + enableRegionalAccessBoundary: true + ); + + $metadata = $serviceAccountCreds->updateMetadata([], null, $httpHandler); + + $this->assertArrayHasKey('x-allowed-locations', $metadata); + $this->assertEquals('foo', $metadata['x-allowed-locations']); + } +} diff --git a/Auth/tests/Credentials/GCECredentialsTest.php b/Auth/tests/Credentials/GCECredentialsTest.php new file mode 100644 index 000000000000..278734fef4f8 --- /dev/null +++ b/Auth/tests/Credentials/GCECredentialsTest.php @@ -0,0 +1,834 @@ +skipResidencyCheck(false); + parent::tearDown(); + } + + public function testOnGceMetadataFlavorHeader() + { + $hasHeader = false; + $dummyHandler = function ($request) use (&$hasHeader) { + $hasHeader = $request->getHeaderLine(GCECredentials::FLAVOR_HEADER) === 'Google'; + + return new Psr7\Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']); + }; + + $onGce = GCECredentials::onGce($dummyHandler); + $this->assertTrue($hasHeader); + $this->assertTrue($onGce); + } + + public function testOnGceMetricsHeader() + { + $handerInvoked = false; + $dummyHandler = function ($request) use (&$handerInvoked) { + $header = $request->getHeaderLine('x-goog-api-client'); + $handerInvoked = true; + $this->assertStringMatchesFormat( + 'gl-php/%s auth/%s auth-request-type/mds', + $header + ); + + return new Psr7\Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']); + }; + + GCECredentials::onGce($dummyHandler); + $this->assertTrue($handerInvoked); + } + + public function testOnGCEIsFalseOnClientErrorStatus() + { + $this->skipResidencyCheck(); + + // simulate retry attempts by returning multiple 400s + $httpHandler = $this->getHandler([ + new Response(400), + new Response(400), + new Response(400) + ]); + $this->assertFalse(GCECredentials::onGCE($httpHandler)); + } + + public function testOnGCEIsFalseOnServerErrorStatus() + { + $this->skipResidencyCheck(); + + // simulate retry attempts by returning multiple 500s + $httpHandler = $this->getHandler([ + new Response(500), + new Response(500), + new Response(500) + ]); + $this->assertFalse(GCECredentials::onGCE($httpHandler)); + } + + public function testOnGCEIsFalseOnNetworkError() + { + $this->skipResidencyCheck(); + + // simulate retry attempts by returning multiple network errors + $httpHandler = $this->getHandler([ + new ConnectException('Connection refused', new Request('GET', 'test')), + new ConnectException('Connection refused', new Request('GET', 'test')), + new ConnectException('Connection refused', new Request('GET', 'test')), + ]); + $this->assertFalse(GCECredentials::onGCE($httpHandler)); + } + + public function testCheckProductNameFile() + { + $tmpFile = tempnam(sys_get_temp_dir(), 'gce-test-product-name'); + + $method = (new ReflectionClass(GCECredentials::class)) + ->getMethod('detectResidencyLinux'); + + $this->assertFalse($method->invoke(null, '/nonexistant/file')); + + file_put_contents($tmpFile, 'Google'); + $this->assertTrue($method->invoke(null, $tmpFile)); + + file_put_contents($tmpFile, 'Not Google'); + $this->assertFalse($method->invoke(null, $tmpFile)); + } + + public function testOnGceWithResidency() + { + if (!GCECredentials::onGCE()) { + $this->markTestSkipped('This test only works while running on GCE'); + } + + // If calling metadata server fails, this will check the residency file. + $httpHandler = function () { + // Mock an exception, such as a ping timeout + throw $this->prophesize(ClientException::class)->reveal(); + }; + + $this->assertTrue(GCECredentials::onGCE($httpHandler)); + } + + public function testOnWindowsGceWithResidencyWithNoCom() + { + if (PHP_OS !== 'Windows' && PHP_OS !== 'WINNT') { + $this->markTestSkipped('This test only works while running on Windows'); + } + + if (class_exists(COM::class)) { + throw $this->markTestSkipped('This test in meant to handle when the COM extension is not present'); + } + + $method = (new ReflectionClass(GCECredentials::class)) + ->getMethod('detectResidencyWindows'); + + $this->assertFalse($method->invoke(null, 'thisShouldBeFalse')); + } + + public function testOnWindowsGceWithResidencyNotOnGCE() + { + if (!class_exists(COM::class)) { + throw $this->markTestSkipped('This test only works while running on windows COM extension enabled'); + } + + if (GCECredentials::onGce()) { + $this->markTestSkipped('This test runs only on non GCE machines'); + } + + $keyPathProperty = 'HKEY_LOCAL_MACHINE\\SYSTEM\\HardwareConfig\\Current\\'; + $keyName = 'SystemProductName'; + + $method = (new ReflectionClass(GCECredentials::class)) + ->getMethod('detectResidencyWindows'); + + $this->assertFalse($method->invoke(null, $keyPathProperty . $keyName)); + } + + public function testOnWindowsGceWithResidency() + { + if (PHP_OS !== 'Windows' && PHP_OS !== 'WINNT') { + $this->markTestSkipped('This test only works while running on Windows'); + } + + if (!class_exists(COM::class)) { + $this->markTestSkipped('This test only works with the COM extension enabled'); + } + + if (!GCECredentials::onGce()) { + $this->markTestSkipped('This test only works while running on GCE'); + } + + $keyPathProperty = 'HKEY_LOCAL_MACHINE\\SYSTEM\\HardwareConfig\\Current\\'; + $keyName = 'SystemProductName'; + + $method = (new ReflectionClass(GCECredentials::class)) + ->getMethod('detectResidencyWindows'); + + $this->assertTrue($method->invoke(null, $keyPathProperty . $keyName)); + } + + public function testOnGCEIsFalseOnOkStatusWithoutExpectedHeader() + { + $httpHandler = $this->getHandler([ + new Response(200), + ]); + $this->assertFalse(GCECredentials::onGCE($httpHandler)); + } + + public function testOnGCEIsOkIfGoogleIsTheFlavor() + { + $httpHandler = $this->getHandler([ + new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']), + ]); + $this->assertTrue(GCECredentials::onGCE($httpHandler)); + } + + public function testOnAppEngineFlexIsFalseByDefault() + { + $this->assertFalse(GCECredentials::onAppEngineFlexible()); + } + + /** + * @runInSeparateProcess + */ + public function testOnAppEngineFlexIsTrueWhenGaeInstanceHasAefPrefix() + { + putenv('GAE_INSTANCE=aef-default-20180313t154438'); + $this->assertTrue(GCECredentials::onAppEngineFlexible()); + putenv('GAE_INSTANCE'); + } + + public function testGetCacheKeyShouldNotBeEmpty() + { + $g = new GCECredentials(); + $this->assertNotEmpty($g->getCacheKey()); + } + + public function testFetchAuthTokenShouldBeEmptyIfNotOnGCE() + { + $this->skipResidencyCheck(); + + // simulate retry attempts by returning multiple 500s + $httpHandler = $this->getHandler([ + new Response(500), + new Response(500), + new Response(500) + ]); + $g = new GCECredentials(); + $this->assertEquals([], $g->fetchAuthToken($httpHandler)); + } + + public function testFetchAuthTokenShouldFailIfResponseIsNotJson() + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Invalid JSON response'); + + $notJson = '{"foo": , this is cannot be passed as json" "bar"}'; + $httpHandler = $this->getHandler([ + new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']), + new Response(200, [], $notJson), + ]); + $g = new GCECredentials(); + $g->fetchAuthToken($httpHandler); + } + + public function testFetchAuthTokenShouldReturnTokenInfo() + { + $wantedTokens = [ + 'access_token' => '1/abdef1234567890', + 'expires_in' => '57', + 'token_type' => 'Bearer', + ]; + $jsonTokens = json_encode($wantedTokens); + $httpHandler = $this->getHandler([ + new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']), + new Response(200, [], Utils::streamFor($jsonTokens)), + ]); + $g = new GCECredentials(); + $receivedToken = $g->fetchAuthToken($httpHandler); + $this->assertEquals( + $wantedTokens['access_token'], + $receivedToken['access_token'] + ); + $this->assertEquals(time() + 57, $receivedToken['expires_at']); + $this->assertEquals(time() + 57, $g->getLastReceivedToken()['expires_at']); + } + + public function testFetchAuthTokenShouldBeIdTokenWhenTargetAudienceIsSet() + { + $expectedToken = ['id_token' => 'idtoken12345']; + $timesCalled = 0; + $httpHandler = function ($request) use (&$timesCalled, $expectedToken) { + $timesCalled++; + if ($timesCalled == 1) { + return new Psr7\Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']); + } + $this->assertEquals( + '/computeMetadata/' . GCECredentials::ID_TOKEN_URI_PATH, + $request->getUri()->getPath() + ); + $this->assertEquals( + 'audience=a+target+audience', + $request->getUri()->getQuery() + ); + return new Psr7\Response(200, [], Utils::streamFor($expectedToken['id_token'])); + }; + $g = new GCECredentials(null, null, 'a+target+audience'); + $this->assertEquals($expectedToken, $g->fetchAuthToken($httpHandler)); + $this->assertEquals(2, $timesCalled); + } + + public function testSettingBothScopeAndTargetAudienceThrowsException() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Scope and targetAudience cannot both be supplied'); + + $g = new GCECredentials(null, 'a-scope', 'a+target+audience'); + } + + /** + * @dataProvider scopes + * @runInSeparateProcess + */ + public function testFetchAuthTokenCustomScope($scope, $expected) + { + $uri = null; + $client = $this->prophesize('GuzzleHttp\ClientInterface'); + $client->send(Argument::any(), Argument::any()) + ->will(function () use (&$uri) { + $this->send(Argument::any(), Argument::any())->will(function ($args) use (&$uri) { + $uri = $args[0]->getUri(); + + return new Response(200, [], Utils::streamFor('{"expires_in": 0}')); + }); + + return new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']); + }); + + HttpClientCache::setHttpClient($client->reveal()); + + $g = new GCECredentials(null, $scope); + $g->fetchAuthToken(); + parse_str($uri->getQuery(), $query); + + $this->assertArrayHasKey('scopes', $query); + $this->assertEquals($expected, $query['scopes']); + } + + public function scopes() + { + return [ + ['foobar', 'foobar'], + [['foobar'], 'foobar'], + ['hello world', 'hello,world'], + [['hello', 'world'], 'hello,world'] + ]; + } + + public function testGetLastReceivedTokenIsNullByDefault() + { + $creds = new GCECredentials(); + $this->assertNull($creds->getLastReceivedToken()); + } + + public function testGetLastReceivedTokenShouldWorkWithIdToken() + { + $idToken = '123asdfghjkl'; + $httpHandler = $this->getHandler([ + new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']), + new Response(200, [], Utils::streamFor($idToken)), + ]); + $g = new GCECredentials(null, null, 'https://example.test.com'); + $g->fetchAuthToken($httpHandler); + $this->assertEquals( + $idToken, + $g->getLastReceivedToken()['id_token'] + ); + } + + public function testGetClientName() + { + $expected = 'foobar'; + + $httpHandler = $this->getHandler([ + new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']), + new Response(200, [], Utils::streamFor($expected)), + new Response(200, [], Utils::streamFor('notexpected')) + ]); + + $creds = new GCECredentials(); + $this->assertEquals($expected, $creds->getClientName($httpHandler)); + + // call again to test cached value + $this->assertEquals($expected, $creds->getClientName($httpHandler)); + } + + public function testGetClientNameShouldBeEmptyIfNotOnGCE() + { + $this->skipResidencyCheck(); + + // simulate retry attempts by returning multiple 500s + $httpHandler = $this->getHandler([ + new Response(500), + new Response(500), + new Response(500) + ]); + + $creds = new GCECredentials(); + $this->assertEquals('', $creds->getClientName($httpHandler)); + } + + /** + * @runInSeparateProcess + */ + public function testSignBlob() + { + $expectedEmail = 'test@test.com'; + $expectedAccessToken = 'token'; + $stringToSign = 'inputString'; + $resultString = 'foobar'; + $token = [ + 'access_token' => $expectedAccessToken, + 'expires_in' => '57', + 'token_type' => 'Bearer', + ]; + + $iam = $this->prophesize('Google\Auth\Iam'); + $iam->signBlob($expectedEmail, $expectedAccessToken, $stringToSign) + ->shouldBeCalled() + ->willReturn($resultString); + + $client = $this->prophesize('GuzzleHttp\ClientInterface'); + $client->send(Argument::any(), Argument::any()) + ->willReturn( + new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']), + new Response(200, [], Utils::streamFor($expectedEmail)), + new Response(200, [], Utils::streamFor(json_encode($token))) + ); + + HttpClientCache::setHttpClient($client->reveal()); + + $creds = new GCECredentials($iam->reveal()); + $signature = $creds->signBlob($stringToSign); + } + + /** + * @runInSeparateProcess + */ + public function testSignBlobWithLastReceivedAccessToken() + { + $expectedEmail = 'test@test.com'; + $expectedAccessToken = 'token'; + $notExpectedAccessToken = 'othertoken'; + $stringToSign = 'inputString'; + $resultString = 'foobar'; + $token1 = [ + 'access_token' => $expectedAccessToken, + 'expires_in' => '57', + 'token_type' => 'Bearer', + ]; + $token2 = [ + 'access_token' => $notExpectedAccessToken, + 'expires_in' => '57', + 'token_type' => 'Bearer', + ]; + + $iam = $this->prophesize('Google\Auth\Iam'); + $iam->signBlob($expectedEmail, $expectedAccessToken, $stringToSign) + ->shouldBeCalled() + ->willReturn($resultString); + + $client = $this->prophesize('GuzzleHttp\ClientInterface'); + $client->send(Argument::any(), Argument::any()) + ->willReturn( + new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']), + new Response(200, [], Utils::streamFor(json_encode($token1))), + new Response(200, [], Utils::streamFor($expectedEmail)), + new Response(200, [], Utils::streamFor(json_encode($token2))) + ); + + HttpClientCache::setHttpClient($client->reveal()); + + $creds = new GCECredentials($iam->reveal()); + // cache a token + $creds->fetchAuthToken(); + + $signature = $creds->signBlob($stringToSign); + } + + /** + * @runInSeparateProcess + */ + public function testSignBlobWithUniverseDomain() + { + $token = [ + 'access_token' => 'token', + 'expires_in' => '57', + 'token_type' => 'Bearer', + ]; + $signedBlob = ['signedBlob' => 'abc123']; + $client = $this->prophesize('GuzzleHttp\ClientInterface'); + $client->send(Argument::any(), Argument::any()) + ->willReturn( + new Response(200, [], Utils::streamFor('test@test.com')), + new Response(200, [], Utils::streamFor(json_encode($token))) + ); + $client->send( + Argument::that( + fn (Request $request) => $request->getUri()->getHost() === 'iamcredentials.example-universe.com' + ), + Argument::any() + ) + ->shouldBeCalledOnce() + ->willReturn(new Response(200, [], Utils::streamFor(json_encode($signedBlob)))); + + HttpClientCache::setHttpClient($client->reveal()); + + $creds = new GCECredentials( + null, + null, + null, + null, + null, + 'example-universe.com' + ); + $creds->setIsOnGce(true); + $signature = $creds->signBlob('inputString'); + $this->assertEquals('abc123', $signature); + } + + /** + * @runInSeparateProcess + */ + public function testGetProjectId() + { + $expected = 'foobar'; + + $client = $this->prophesize('GuzzleHttp\ClientInterface'); + $client->send(Argument::any(), Argument::any()) + ->willReturn( + new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']), + new Response(200, [], Utils::streamFor($expected)), + new Response(200, [], Utils::streamFor('notexpected')) + ); + + HttpClientCache::setHttpClient($client->reveal()); + + $creds = new GCECredentials(); + $this->assertEquals($expected, $creds->getProjectId()); + + // call again to test cached value + $this->assertEquals($expected, $creds->getProjectId()); + } + + /** + * @runInSeparateProcess + */ + public function testGetProjectIdShouldBeEmptyIfNotOnGCE() + { + // simulate retry attempts by returning multiple 500s + $client = $this->prophesize('GuzzleHttp\ClientInterface'); + $client->send(Argument::any(), Argument::any()) + ->willReturn( + new Response(500), + new Response(500), + new Response(500) + ); + + HttpClientCache::setHttpClient($client->reveal()); + + $creds = new GCECredentials(); + $this->assertNull($creds->getProjectId()); + } + + public function testGetTokenUriWithServiceAccountIdentity() + { + $tokenUri = GCECredentials::getTokenUri('foo'); + $this->assertEquals( + 'http://169.254.169.254/computeMetadata/v1/instance/service-accounts/foo/token', + $tokenUri + ); + } + + public function testSetIsOnGceToFalseReturnsEmptyCreds() + { + $creds = new GCECredentials(); + $creds->setIsOnGce(false); + $this->assertEquals([], $creds->fetchAuthToken()); + } + + public function testSetIsOnGceToTrueWhenNotOnGceThrowsException() + { + $this->expectException(ClientException::class); + $this->expectExceptionMessage('408 Request Time-out'); + + $httpHandler = $this->getHandler([new Response(408)]); + $creds = new GCECredentials(); + $creds->setIsOnGce(true); + $creds->fetchAuthToken($httpHandler); + } + + public function testGetAccessTokenWithServiceAccountIdentity() + { + $expected = [ + 'access_token' => 'token12345', + 'expires_in' => 123, + ]; + $timesCalled = 0; + $httpHandler = function ($request) use (&$timesCalled, $expected) { + $timesCalled++; + if ($timesCalled == 1) { + return new Psr7\Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']); + } + $this->assertEquals( + '/computeMetadata/v1/instance/service-accounts/foo/token', + $request->getUri()->getPath() + ); + $this->assertEquals('', $request->getUri()->getQuery()); + return new Psr7\Response(200, [], Utils::streamFor(json_encode($expected))); + }; + + $g = new GCECredentials(null, null, null, null, 'foo'); + $this->assertEquals( + $expected['access_token'], + $g->fetchAuthToken($httpHandler)['access_token'] + ); + } + + public function testGetIdTokenWithServiceAccountIdentity() + { + $expected = 'idtoken12345'; + $timesCalled = 0; + $httpHandler = function ($request) use (&$timesCalled, $expected) { + $timesCalled++; + if ($timesCalled == 1) { + return new Psr7\Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']); + } + $this->assertEquals( + '/computeMetadata/v1/instance/service-accounts/foo/identity', + $request->getUri()->getPath() + ); + $this->assertEquals( + 'audience=a+target+audience', + $request->getUri()->getQuery() + ); + return new Psr7\Response(200, [], Utils::streamFor($expected)); + }; + $g = new GCECredentials(null, null, 'a+target+audience', null, 'foo'); + $this->assertEquals( + ['id_token' => $expected], + $g->fetchAuthToken($httpHandler) + ); + } + + public function testGetClientNameUriWithServiceAccountIdentity() + { + $clientNameUri = GCECredentials::getClientNameUri('foo'); + $this->assertEquals( + 'http://169.254.169.254/computeMetadata/v1/instance/service-accounts/foo/email', + $clientNameUri + ); + } + + public function testGetClientNameWithServiceAccountIdentity() + { + $expected = 'expected'; + $timesCalled = 0; + $httpHandler = function ($request) use (&$timesCalled, $expected) { + $timesCalled++; + if ($timesCalled == 1) { + return new Psr7\Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']); + } + $this->assertEquals( + '/computeMetadata/v1/instance/service-accounts/foo/email', + $request->getUri()->getPath() + ); + $this->assertEquals('', $request->getUri()->getQuery()); + return new Psr7\Response(200, [], Utils::streamFor($expected)); + }; + + $creds = new GCECredentials(null, null, null, null, 'foo'); + $this->assertEquals($expected, $creds->getClientName($httpHandler)); + } + + public function testGetUniverseDomain() + { + $creds = new GCECredentials(); + $creds->setIsOnGce(true); + + // Pretend we are on GCE and mock the http handler. + $expected = 'example-universe.com'; + $timesCalled = 0; + $httpHandler = function ($request) use (&$timesCalled, $expected) { + $timesCalled++; + $this->assertEquals( + '/computeMetadata/v1/universe/universe-domain', + $request->getUri()->getPath() + ); + $this->assertEquals(1, $timesCalled, 'should only be called once'); + return new Psr7\Response(200, [], Utils::streamFor($expected)); + }; + + // Assert correct universe domain. + $this->assertEquals($expected, $creds->getUniverseDomain($httpHandler)); + + // Assert the result is cached for subsequent calls. + $this->assertEquals($expected, $creds->getUniverseDomain($httpHandler)); + } + + public function testGetUniverseDomainEmptyStringReturnsDefault() + { + $creds = new GCECredentials(); + $creds->setIsOnGce(true); + + // Pretend we are on GCE and mock the MDS returning an empty string for the universe domain. + $httpHandler = function ($request) { + $this->assertEquals( + '/computeMetadata/v1/universe/universe-domain', + $request->getUri()->getPath() + ); + return new Psr7\Response(200, [], Utils::streamFor('')); + }; + + // Assert the default universe domain is returned instead of the empty string. + $this->assertEquals( + GCECredentials::DEFAULT_UNIVERSE_DOMAIN, + $creds->getUniverseDomain($httpHandler) + ); + } + + public function testGetUniverseDomainNotFoundReturnsDefault() + { + $creds = new GCECredentials(); + $creds->setIsOnGce(true); + + // Pretend we are on GCE and mock the MDS returning a 404 for the universe domain. + $httpHandler = $this->getHandler([ + new Response(404), + ]); + + // Assert the default universe domain is returned instead of the error being thrown. + $this->assertEquals( + GCECredentials::DEFAULT_UNIVERSE_DOMAIN, + $creds->getUniverseDomain($httpHandler) + ); + } + + public function testExplicitUniverseDomain() + { + $expected = 'example-universe.com'; + $creds = new GCECredentials(null, null, null, null, null, $expected); + $this->assertEquals($expected, $creds->getUniverseDomain()); + } + + public function testUpdateMetadataWithRegionalAccessBoundary() + { + $timesCalled = 0; + $httpHandler = function () use (&$timesCalled) { + return match (++$timesCalled) { + 1 => new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']), + 2 => new Response(200, [], '{"access_token": "abc", "expires_in": 57}'), + 3 => new Response(200, [], '1234567890-compute@developer.gserviceaccount.com'), + 4 => new Response(200, [], '{"locations": [], "encodedLocations": "foo"}'), + }; + }; + + $gceCreds = new GCECredentials( + enableRegionalAccessBoundary: true, + universeDomain: GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN, + ); + + $metadata = $gceCreds->updateMetadata([], null, $httpHandler); + + $this->assertArrayHasKey('x-allowed-locations', $metadata); + $this->assertEquals('foo', $metadata['x-allowed-locations']); + } + + public function testUpdateMetadataWithRegionalAccessBoundarySuppressedWithUniverseDomain() + { + $timesCalled = 0; + $httpHandler = function () use (&$timesCalled) { + return match (++$timesCalled) { + 1 => new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']), + 2 => new Response(200, [], '{"access_token": "abc", "expires_in": 57}'), + 3 => new Response(200, [], '1234567890-compute@developer.gserviceaccount.com'), + }; + }; + + $gceCreds = new GCECredentials( + enableRegionalAccessBoundary: true, + universeDomain: 'foo.com' + ); + + $metadata = $gceCreds->updateMetadata([], null, $httpHandler); + + $this->assertArrayNotHasKey('x-allowed-locations', $metadata); + } + + public function testUpdateMetadataWithInvalidEmailBypassesRegionalAccessBoundary() + { + $timesCalled = 0; + $httpHandler = function () use (&$timesCalled) { + return match (++$timesCalled) { + 1 => new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']), + 2 => new Response(200, [], '{"access_token": "abc", "expires_in": 57}'), + 3 => new Response(200, [], 'not-an-email'), + 4 => new Response(200, [], '{"locations": [], "encodedLocations": "foo"}'), + }; + }; + + $gceCreds = new GCECredentials( + enableRegionalAccessBoundary: true, + universeDomain: GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN, + ); + + $metadata = $gceCreds->updateMetadata([], null, $httpHandler); + + $this->assertArrayNotHasKey('x-allowed-locations', $metadata); + } +} diff --git a/Auth/tests/Credentials/IAMCredentialsTest.php b/Auth/tests/Credentials/IAMCredentialsTest.php new file mode 100644 index 000000000000..8e1c5f7dc743 --- /dev/null +++ b/Auth/tests/Credentials/IAMCredentialsTest.php @@ -0,0 +1,91 @@ +expectException(InvalidArgumentException::class); + + $notAString = new \stdClass(); + $iam = new IAMCredentials( + $notAString, + '' + ); + } + + public function testShouldFailIfTokenIsNotString() + { + $this->expectException(InvalidArgumentException::class); + + $notAString = new \stdClass(); + $iam = new IAMCredentials( + '', + $notAString + ); + } + + public function testInitializeSuccess() + { + $this->assertNotNull( + new IAMCredentials('iam-selector', 'iam-token') + ); + } +} + +class IAMUpdateMetadataCallbackTest extends TestCase +{ + public function testUpdateMetadataFunc() + { + $selector = 'iam-selector'; + $token = 'iam-token'; + $iam = new IAMCredentials( + $selector, + $token + ); + + $update_metadata = $iam->getUpdateMetadataFunc(); + $this->assertTrue(is_callable($update_metadata)); + + $actual_metadata = call_user_func( + $update_metadata, + $metadata = ['foo' => 'bar'] + ); + $this->assertArrayHasKey(IAMCredentials::SELECTOR_KEY, $actual_metadata); + $this->assertEquals( + $actual_metadata[IAMCredentials::SELECTOR_KEY], + $selector + ); + $this->assertArrayHasKey(IAMCredentials::TOKEN_KEY, $actual_metadata); + $this->assertEquals( + $actual_metadata[IAMCredentials::TOKEN_KEY], + $token + ); + } +} diff --git a/Auth/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php b/Auth/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php new file mode 100644 index 000000000000..52bb5df4facd --- /dev/null +++ b/Auth/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php @@ -0,0 +1,629 @@ + 'impersonated_service_account', + 'service_account_impersonation_url' => self::IMPERSONATION_URL, + 'source_credentials' => [ + 'client_id' => 'client123', + 'client_secret' => 'clientSecret123', + 'refresh_token' => 'refreshToken123', + 'type' => 'authorized_user', + ] + ]; + + // Service Account to Service Account Impersonation JSON Credentials + private const SERVICE_ACCOUNT_TO_SERVICE_ACCOUNT_JSON = [ + 'type' => 'impersonated_service_account', + 'service_account_impersonation_url' => self::IMPERSONATION_URL, + 'source_credentials' => [ + 'client_email' => 'clientemail@clientemail.com', + // phpcs:ignore Generic.Files.LineLength + 'private_key' => "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA0Ttga33B1yX4w77NbpKyNYDNSVCo8j+RlZaZ9tI+KfkV1d+t\nfsvI9ZPAheP11FoN52ceBaY5ltelHW+IKwCfyT0orLdsxLgowaXki9woF1Azvcg2\nJVxQLv9aVjjAvy3CZFIG/EeN7J3nsyCXGnu1yMEbnvkWxA88//Q6HQ2K9wqfApkQ\n0LNlsK0YHz/sfjHNvRKxnbAJk7D5fUhZunPZXOPHXFgA5SvLvMaNIXduMKJh4OMf\nuoLdJowXJAR9j31Mqz/is4FMhm/9Mq7vZZ+uF09htRvIR8tRY28oJuW1gKWyg7cQ\nQpnjHgFyG3XLXWAeXclWqyh/LfjyHQjrYhyeFwIDAQABAoIBAHMqdJsWAGEVNIVB\n+792HYNXnydQr32PwemNmLeD59WglgU/9jZJoxaROjI4VLKK0wZg+uRvJ1nA3tCB\n+Hh7Anh5Im9XExaAq2ZTkqXtC2AxtBktH6iW1EfaI/Y7jNRuMoaXo+Ku3A62p7cw\nJBvepiOXL0Xko0RNguz7mBUvxCLPhYhzn7qCbM8uXLcjsXq/YhWQwQmtMqv0sd3W\nHy+8Jb2c18sqDeZIBne4dWD6qPClPEOsrq9gPTkl0DjbT27oVc2u1p4HMNm5BJIh\nu3rMSxnZHUd7Axj1FgyLIOHl63UhaiaA1aPe/fLiVIGOA1jBZrpbnjgqDy9Uxyn6\neydbiwECgYEA9mtRydz22idyUOlBCDXk+vdGBvFAucNYaNNUAXUJ2wfPmdGgFCA7\ng5eQG8JC6J/FU+2AfIuz6LGr7SxMBYcsWGjFAzGqs/sJib+zzN1dPUSRn4uJNFit\n51yQzPgBqHS6S/XBi6YAODeZDl9jiPl3FxxucqLY5NstqZFXbE0SjIECgYEA2V3r\n7xnRAK1krY1+zkPof4kcBmjqOXjnl/oRxlXP65lEXmyNJwm/ulOIko9mElWRs8CG\nAxSWKaab9Gk6lc8MHjVRbuW52RGLGKq1mp6ENr4d3IBOfrNsTvD3gtNEN1JFLeF1\njIbSsrbi2txr7VZ06Irac0C/ytro0QDOUoXkvpcCgYA8O0EzmToRWsD7e/g0XJAK\ns/Q+8CtE/LWYccc/z+7HxeH9lBqPsM07Pgmwb0xRdfQSrqPQTYl9ICiJAWHXnBG/\nzmQRgstZ0MulCuGU+qq2thLuL3oq/F4NhjeykhA9r8J1nK1hSAMXuqdDtxcqPOfa\nE03/4UQotFY181uuEiytgQKBgHQT+gjHqptH/XnJFCymiySAXdz2bg6fCF5aht95\nt/1C7gXWxlJQnHiuX0KVHZcw5wwtBePjPIWlmaceAtE5rmj7ZC9qsqK/AZ78mtql\nSEnLoTq9si1rN624dRUCKW25m4Py4MlYvm/9xovGJkSqZOhCLoJZ05JK8QWb/pKH\nOi6lAoGBAOUN6ICpMQvzMGPgIbgS0H/gvRTnpAEs59vdgrkhlCII4tzfgvBQlVae\nhRcdM6GTMq5pekBPKu45eanIzwVc88P6coT4qiWYKk2jYoLBa0UV3xEAuqBMymrj\nX4nLcSbZtO0tcDGMfMpWF2JGYOEJQNetPozL/ICGVFyIO8yzXm8U\n-----END RSA PRIVATE KEY-----\n", + 'type' => 'service_account', + ] + ]; + + // Service Account to Service Account Impersonation JSON Credentials + private const EXTERNAL_ACCOUNT_TO_SERVICE_ACCOUNT_JSON = [ + 'type' => 'impersonated_service_account', + 'service_account_impersonation_url' => self::IMPERSONATION_URL, + 'source_credentials' => [ + 'type' => 'external_account', + 'audience' => 'some_audience', + 'subject_token_type' => 'access_token', + 'token_url' => 'https://sts.googleapis.com/v1/token', + 'credential_source' => [ + 'url' => 'https://some.url/token' + ] + ] + ]; + + public function testGetServiceAccountNameEmail() + { + $json = self::USER_TO_SERVICE_ACCOUNT_JSON; + $creds = new ImpersonatedServiceAccountCredentials(self::SCOPE, $json); + $this->assertEquals('test@test-project.iam.gserviceaccount.com', $creds->getClientName()); + } + + public function testGetServiceAccountNameID() + { + $json = self::USER_TO_SERVICE_ACCOUNT_JSON; + $json['service_account_impersonation_url'] = + 'https://some/arbitrary/url/serviceAccounts/1234567890987654321:generateAccessToken'; + $creds = new ImpersonatedServiceAccountCredentials(self::SCOPE, $json); + $this->assertEquals('1234567890987654321', $creds->getClientName()); + } + + public function testGetCacheKey() + { + $creds = new ImpersonatedServiceAccountCredentials(self::SCOPE, [ + 'service_account_impersonation_url' => 'foo', + 'source_credentials' => [ + 'type' => 'service_account', + 'client_email' => '123', + 'private_key' => 'abc' + ] + ]); + $this->assertEquals('foo123.scope1scope2', $creds->getCacheKey()); + } + + public function testMissingImpersonationUriThrowsException() + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('json key is missing the service_account_impersonation_url field'); + + new ImpersonatedServiceAccountCredentials(self::SCOPE, []); + } + + public function testMissingSourceCredentialTypeThrowsException() + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('json key source credentials are missing the type field'); + + new ImpersonatedServiceAccountCredentials(self::SCOPE, [ + 'service_account_impersonation_url' => 'https//google.com', + 'source_credentials' => [] + ]); + } + + /** + * @dataProvider provideSourceCredentialsClass + */ + public function testSourceCredentialsClass(array $json, string $credClass) + { + $creds = new ImpersonatedServiceAccountCredentials(['scope/1', 'scope/2'], $json); + + $sourceCredentialsProperty = (new ReflectionClass($creds))->getProperty('sourceCredentials'); + $this->assertInstanceOf($credClass, $sourceCredentialsProperty->getValue($creds)); + } + + public function provideSourceCredentialsClass() + { + return [ + [self::USER_TO_SERVICE_ACCOUNT_JSON, UserRefreshCredentials::class], + [self::SERVICE_ACCOUNT_TO_SERVICE_ACCOUNT_JSON, ServiceAccountCredentials::class], + [self::EXTERNAL_ACCOUNT_TO_SERVICE_ACCOUNT_JSON, ExternalAccountCredentials::class], + ]; + } + + /** + * Test access token impersonation for Service Account and User Refresh Credentials. + * + * @dataProvider provideAuthTokenJson + */ + public function testGetAccessTokenWithServiceAccountAndUserRefreshCredentials(array $json, string $grantType) + { + $requestCount = 0; + // getting an id token will take two requests + $httpHandler = function (RequestInterface $request) use (&$requestCount, $json, $grantType) { + if (++$requestCount == 1) { + // the call to swap the refresh token for an access token + $this->assertEquals(UserRefreshCredentials::TOKEN_CREDENTIAL_URI, (string) $request->getUri()); + parse_str((string) $request->getBody(), $result); + $this->assertEquals($grantType, $result['grant_type']); + } elseif ($requestCount == 2) { + // the call to swap the access token for an id token + $this->assertEquals($json['service_account_impersonation_url'], (string) $request->getUri()); + $this->assertEquals(self::SCOPE, json_decode($request->getBody(), true)['scope'] ?? ''); + $this->assertEquals('Bearer test-access-token', $request->getHeader('authorization')[0] ?? null); + } + + return new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(match ($requestCount) { + 1 => ['access_token' => 'test-access-token'], + 2 => ['accessToken' => 'test-impersonated-access-token', 'expireTime' => 123] + }) + ); + }; + + $creds = new ImpersonatedServiceAccountCredentials(self::SCOPE, $json); + $token = $creds->fetchAuthToken($httpHandler); + $this->assertEquals('test-impersonated-access-token', $token['access_token']); + $this->assertEquals(2, $requestCount); + $this->assertEquals($token, $creds->getLastReceivedToken()); + } + + /** + * Test access token impersonation for Exernal Account Credentials. + */ + public function testGetAccessTokenWithExternalAccountCredentials() + { + $json = self::EXTERNAL_ACCOUNT_TO_SERVICE_ACCOUNT_JSON; + $httpHandler = function (RequestInterface $request) use (&$requestCount, $json) { + if (++$requestCount == 1) { + // the call to swap the refresh token for an access token + $this->assertEquals( + $json['source_credentials']['credential_source']['url'], + (string) $request->getUri() + ); + } elseif ($requestCount == 2) { + $this->assertEquals($json['source_credentials']['token_url'], (string) $request->getUri()); + } elseif ($requestCount == 3) { + // the call to swap the access token for an id token + $this->assertEquals($json['service_account_impersonation_url'], (string) $request->getUri()); + $this->assertEquals(self::SCOPE, json_decode($request->getBody(), true)['scope'] ?? ''); + $this->assertEquals('Bearer test-access-token', $request->getHeader('authorization')[0] ?? null); + } + + return new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(match ($requestCount) { + 1 => ['access_token' => 'test-access-token'], + 2 => ['access_token' => 'test-access-token'], + 3 => ['accessToken' => 'test-impersonated-access-token', 'expireTime' => 123] + }) + ); + }; + + $creds = new ImpersonatedServiceAccountCredentials(self::SCOPE, $json); + $token = $creds->fetchAuthToken($httpHandler); + $this->assertEquals('test-impersonated-access-token', $token['access_token']); + $this->assertEquals(3, $requestCount); + $this->assertEquals($token, $creds->getLastReceivedToken()); + } + + /** + * Test ID token impersonation for Service Account and User Refresh Credentials. + * + * @dataProvider provideAuthTokenJson + */ + public function testGetIdTokenWithServiceAccountAndUserRefreshCredentials(array $json, string $grantType) + { + $requestCount = 0; + // getting an id token will take two requests + $httpHandler = function (RequestInterface $request) use (&$requestCount, $json, $grantType) { + if (++$requestCount == 1) { + // the call to swap the refresh token for an access token + $this->assertEquals(UserRefreshCredentials::TOKEN_CREDENTIAL_URI, (string) $request->getUri()); + parse_str((string) $request->getBody(), $result); + $this->assertEquals($grantType, $result['grant_type']); + } elseif ($requestCount == 2) { + // the call to swap the access token for an id token + $this->assertEquals( + str_replace(':generateAccessToken', ':generateIdToken', $json['service_account_impersonation_url']), + (string) $request->getUri() + ); + $this->assertEquals(self::TARGET_AUDIENCE, json_decode($request->getBody(), true)['audience'] ?? ''); + $this->assertEquals('Bearer test-access-token', $request->getHeader('authorization')[0] ?? null); + } + + return new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(match ($requestCount) { + 1 => ['access_token' => 'test-access-token'], + 2 => ['token' => 'test-impersonated-id-token'] + }) + ); + }; + + $creds = new ImpersonatedServiceAccountCredentials(null, $json, self::TARGET_AUDIENCE); + $token = $creds->fetchAuthToken($httpHandler); + $this->assertEquals('test-impersonated-id-token', $token['id_token']); + $this->assertEquals(2, $requestCount); + $this->assertEquals($token, $creds->getLastReceivedToken()); + } + + public function provideAuthTokenJson() + { + return [ + [self::USER_TO_SERVICE_ACCOUNT_JSON, 'refresh_token'], + [self::SERVICE_ACCOUNT_TO_SERVICE_ACCOUNT_JSON, OAuth2::JWT_URN], + ]; + } + + /** + * Test ID token impersonation for Service Account Credentials with a universe domain. + */ + public function testGetIdTokenWithServiceAccountCredentialsAndUniverseDomain() + { + $json = self::SERVICE_ACCOUNT_TO_SERVICE_ACCOUNT_JSON; + $json['source_credentials']['universe_domain'] = self::UNIVERSE_DOMAIN; + + // the expected URL should have the universe domain + $expectedUrl = str_replace( + ['googleapis.com', ':generateAccessToken'], + [self::UNIVERSE_DOMAIN, ':generateIdToken'], + $json['service_account_impersonation_url'], + ); + + // getting an id token will take two requests + $httpHandler = function (RequestInterface $request) use ($expectedUrl) { + $this->assertEquals($expectedUrl, (string) $request->getUri()); + $this->assertEquals(self::TARGET_AUDIENCE, json_decode($request->getBody(), true)['audience'] ?? ''); + $this->assertStringStartsWith('Bearer ', $request->getHeader('authorization')[0] ?? null); + + return new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(['token' => 'test-impersonated-id-token']) + ); + }; + + $creds = new ImpersonatedServiceAccountCredentials(null, $json, self::TARGET_AUDIENCE); + $token = $creds->fetchAuthToken($httpHandler); + $this->assertEquals('test-impersonated-id-token', $token['id_token']); + $this->assertEquals($token, $creds->getLastReceivedToken()); + } + + /** + * Test invalid email throws exception + */ + public function testInvalidServiceAccountImpersonationUrlThrowsException() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Invalid service account impersonation URL - unable to parse service account email' + ); + + $json = self::SERVICE_ACCOUNT_TO_SERVICE_ACCOUNT_JSON; + $json['service_account_impersonation_url'] = 'https://invalid/url'; + + // mock access token call for source credentials + $httpHandler = fn () => new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(['access_token' => 'test-access-token']) + ); + + $creds = new ImpersonatedServiceAccountCredentials(null, $json, self::TARGET_AUDIENCE); + $creds->fetchAuthToken($httpHandler); + } + + /** + * Test ID token impersonation for Exernal Account Credentials. + * @dataProvider provideUniverseDomain + */ + public function testGetIdTokenWithExternalAccountCredentials(?string $universeDomain = null) + { + $json = self::EXTERNAL_ACCOUNT_TO_SERVICE_ACCOUNT_JSON; + if ($universeDomain) { + $json['source_credentials']['universe_domain'] = $universeDomain; + } + $httpHandler = function (RequestInterface $request) use (&$requestCount, $json, $universeDomain) { + if (++$requestCount == 1) { + // the call to swap the refresh token for an access token + $this->assertEquals( + $json['source_credentials']['credential_source']['url'], + (string) $request->getUri() + ); + } elseif ($requestCount == 2) { + $this->assertEquals($json['source_credentials']['token_url'], (string) $request->getUri()); + } elseif ($requestCount == 3) { + // the call to swap the access token for an id token + $url = str_replace( + ':generateAccessToken', + ':generateIdToken', + $json['service_account_impersonation_url'] + ); + if ($universeDomain) { + $url = str_replace('googleapis.com', $universeDomain, $url); + } + $this->assertEquals($url, (string) $request->getUri()); + $this->assertEquals(self::TARGET_AUDIENCE, json_decode($request->getBody(), true)['audience'] ?? ''); + $this->assertEquals('Bearer test-access-token', $request->getHeader('authorization')[0] ?? null); + } + + return new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(match ($requestCount) { + 1 => ['access_token' => 'test-access-token'], + 2 => ['access_token' => 'test-access-token'], + 3 => ['token' => 'test-impersonated-id-token'] + }) + ); + }; + + $creds = new ImpersonatedServiceAccountCredentials(null, $json, self::TARGET_AUDIENCE); + $token = $creds->fetchAuthToken($httpHandler); + $this->assertEquals('test-impersonated-id-token', $token['id_token']); + $this->assertEquals(3, $requestCount); + $this->assertEquals($token, $creds->getLastReceivedToken()); + } + + /** + * Test ID token impersonation for an arbitrary credential fetcher. + * @dataProvider provideUniverseDomain + */ + public function testGetIdTokenWithArbitraryCredentials(?string $universeDomain = null) + { + $url = $universeDomain + ? 'https://iamcredentials.' . self::UNIVERSE_DOMAIN . '/v1/projects/-/serviceAccounts/123:generateIdToken' + : 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/123:generateIdToken'; + + $httpHandler = function (RequestInterface $request) use ($url) { + // The URL is coerced to match the googleapis URL pattern + $this->assertEquals($url, (string) $request->getUri()); + $this->assertEquals('Bearer test-access-token', $request->getHeader('authorization')[0] ?? null); + return new Response(200, [], json_encode(['token' => 'test-impersonated-id-token'])); + }; + + $credentials = $this->prophesize(FetchAuthTokenInterface::class) + ->willImplement(GetUniverseDomainInterface::class); + $credentials->fetchAuthToken($httpHandler, Argument::type('array')) + ->shouldBeCalledOnce() + ->willReturn(['access_token' => 'test-access-token']); + $credentials->getUniverseDomain() + ->shouldBeCalledOnce() + ->willReturn($universeDomain ?: GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN); + + $json = [ + 'type' => 'impersonated_service_account', + 'service_account_impersonation_url' => 'https://some/url/serviceAccounts/123:generateAccessToken', + 'source_credentials' => $credentials->reveal(), + ]; + + $creds = new ImpersonatedServiceAccountCredentials(null, $json, self::TARGET_AUDIENCE); + + $token = $creds->fetchAuthToken($httpHandler); + $this->assertEquals('test-impersonated-id-token', $token['id_token']); + $this->assertEquals($token, $creds->getLastReceivedToken()); + } + + public function provideUniverseDomain() + { + return [ + [null], + [self::UNIVERSE_DOMAIN], + ]; + } + + /** + * Test access token impersonation for an arbitrary credential fetcher. + */ + public function testGetAccessTokenWithArbitraryCredentials() + { + $httpHandler = function (RequestInterface $request) { + $this->assertEquals('https://some/url', (string) $request->getUri()); + $this->assertEquals('Bearer test-access-token', $request->getHeader('authorization')[0] ?? null); + return new Response( + 200, + [], + json_encode(['accessToken' => 'test-impersonated-access-token', 'expireTime' => 123]) + ); + }; + + $credentials = $this->prophesize(FetchAuthTokenInterface::class); + $credentials->fetchAuthToken($httpHandler, Argument::type('array')) + ->shouldBeCalledOnce() + ->willReturn(['access_token' => 'test-access-token']); + + $json = [ + 'type' => 'impersonated_service_account', + 'service_account_impersonation_url' => 'https://some/url', + 'source_credentials' => $credentials->reveal(), + ]; + $creds = new ImpersonatedServiceAccountCredentials(self::SCOPE, $json); + + $token = $creds->fetchAuthToken($httpHandler); + $this->assertEquals('test-impersonated-access-token', $token['access_token']); + $this->assertEquals($token, $creds->getLastReceivedToken()); + } + + public function testIdTokenWithAuthTokenMiddleware() + { + $targetAudience = 'test-target-audience'; + $credentials = new ImpersonatedServiceAccountCredentials( + null, + self::USER_TO_SERVICE_ACCOUNT_JSON, + $targetAudience + ); + + // this handler is for the middleware constructor, which will pass it to the ISAC to fetch tokens + $httpHandler = $this->getHandler([ + new Response(200, ['Content-Type' => 'application/json'], '{"access_token":"this.is.an.access.token"}'), + new Response(200, ['Content-Type' => 'application/json'], '{"token":"this.is.an.id.token"}'), + ]); + $middleware = new AuthTokenMiddleware($credentials, $httpHandler); + + // this handler is the actual handler that makes the authenticated request + $requestCount = 0; + $httpHandler = function (RequestInterface $request) use (&$requestCount) { + $requestCount++; + $this->assertTrue($request->hasHeader('authorization')); + $this->assertEquals('Bearer this.is.an.id.token', $request->getHeader('authorization')[0] ?? null); + }; + + $middleware($httpHandler)( + new Request('GET', 'https://www.google.com'), + ['auth' => 'google_auth'] + ); + + $this->assertEquals(1, $requestCount); + } + + /** + * @dataProvider provideScopePrecedence + */ + public function testScopePrecedence( + string|array|null $userScope, + string|array|null $jsonKeyScope, + string|null $defaultScope, + string|array $expectedScope + ) { + $jsonKey = self::SERVICE_ACCOUNT_TO_SERVICE_ACCOUNT_JSON; + $jsonKey['scopes'] = $jsonKeyScope; + $credentials = new ImpersonatedServiceAccountCredentials( + scope: $userScope, + jsonKey: $jsonKey, + defaultScope: $defaultScope, + ); + + $scopeProp = (new ReflectionClass($credentials))->getProperty('targetScope'); + $this->assertEquals($expectedScope, $scopeProp->getValue($credentials)); + } + + public function testScopePrecedenceWithNoJsonKey() + { + $defaultScope = 'a-default-scope'; + $jsonKey = self::SERVICE_ACCOUNT_TO_SERVICE_ACCOUNT_JSON; + $credentials = new ImpersonatedServiceAccountCredentials( + scope: null, + jsonKey: $jsonKey, + defaultScope: $defaultScope, + ); + + $scopeProp = (new ReflectionClass($credentials))->getProperty('targetScope'); + $this->assertEquals($defaultScope, $scopeProp->getValue($credentials)); + } + + public function provideScopePrecedence() + { + $userScope = 'a-user-scope'; + $jsonKeyScope = 'a-json-key-scope'; + $defaultScope = 'a-default-scope'; + return [ + // User scope always takes precendence + [$userScope, $jsonKeyScope, $defaultScope, 'expectedScope' => $userScope], + [$userScope, null, $defaultScope, 'expectedScope' => $userScope], + [$userScope, $jsonKeyScope, null, 'expectedScope' => $userScope], + [$userScope, null, null, 'expectedScope' => $userScope], + + // JSON Key Scope is next + [null, $jsonKeyScope, $defaultScope, 'expectedScope' => $jsonKeyScope], + [null, $jsonKeyScope, null, 'expectedScope' => $jsonKeyScope], + + // Default Scope is last + [null, null, $defaultScope, 'expectedScope' => $defaultScope], + // JSON Key scope is exists but is an empty array, still return default + [null, [], $defaultScope, 'expectedScope' => $defaultScope], + + // No scope is empty array + [null, null, null, 'expectedScope' => []], + + // Test empty strings and arrays + ['', $jsonKeyScope, null, 'expectedScope' => $jsonKeyScope], + [[], $jsonKeyScope, null, 'expectedScope' => $jsonKeyScope], + [[], '', $defaultScope, 'expectedScope' => $defaultScope], + ]; + } + + public function testUpdateMetadataWithRegionalAccessBoundary() + { + $httpHandler = $this->getHandler([ + new Response(200, [], '{"access_token": "source-token", "expires_in": 3600}'), + new Response(200, [], '{"accessToken": "impersonated-token", "expireTime": "2026-01-01"}'), + new Response(200, [], '{"locations": [], "encodedLocations": "foo"}'), + ]); + + $jsonKey = [ + 'service_account_impersonation_url' => 'https://iamcredentials.googleapis.com/v1', + 'source_credentials' => [ + 'type' => 'service_account', + 'private_key' => file_get_contents(__DIR__ . '/../fixtures/fixtures1/private.pem'), + 'client_email' => 'test@example.com', + ], + ]; + $impersonatedCreds = new ImpersonatedServiceAccountCredentials( + 'a-scope', + $jsonKey, + enableRegionalAccessBoundary: true + ); + + $metadata = $impersonatedCreds->updateMetadata([], null, $httpHandler); + + $this->assertArrayHasKey('x-allowed-locations', $metadata); + $this->assertEquals('foo', $metadata['x-allowed-locations']); + } + + public function testUpdateMetadataWithRegionalAccessBoundarySuppressedWithUniverseDomain() + { + $httpHandler = $this->getHandler([ + new Response(200, [], '{"accessToken": "impersonated-token", "expireTime": "2026-01-01"}'), + ]); + + $jsonKey = [ + 'service_account_impersonation_url' => 'https://iamcredentials.googleapis.com/v1', + 'source_credentials' => [ + 'type' => 'service_account', + 'private_key' => file_get_contents(__DIR__ . '/../fixtures/fixtures1/private.pem'), + 'client_email' => 'test@example.com', + 'universe_domain' => 'foo.com' + ], + ]; + $impersonatedCreds = new ImpersonatedServiceAccountCredentials( + 'a-scope', + $jsonKey, + enableRegionalAccessBoundary: true + ); + + $metadata = $impersonatedCreds->updateMetadata([], null, $httpHandler); + + $this->assertArrayNotHasKey('x-allowed-locations', $metadata); + } +} diff --git a/Auth/tests/Credentials/InsecureCredentialsTest.php b/Auth/tests/Credentials/InsecureCredentialsTest.php new file mode 100644 index 000000000000..e8b61c92b231 --- /dev/null +++ b/Auth/tests/Credentials/InsecureCredentialsTest.php @@ -0,0 +1,48 @@ +assertEquals(['access_token' => ''], $insecure->fetchAuthToken()); + } + + public function testGetCacheKey() + { + $insecure = new InsecureCredentials(); + $this->assertNull($insecure->getCacheKey()); + } + + public function testGetLastReceivedToken() + { + $insecure = new InsecureCredentials(); + $this->assertEquals(['access_token' => ''], $insecure->getLastReceivedToken()); + } +} diff --git a/Auth/tests/Credentials/RegionalAccessBoundaryTraitTest.php b/Auth/tests/Credentials/RegionalAccessBoundaryTraitTest.php new file mode 100644 index 000000000000..191a429d9cbf --- /dev/null +++ b/Auth/tests/Credentials/RegionalAccessBoundaryTraitTest.php @@ -0,0 +1,433 @@ +impl = new RegionalAccessBoundaryTraitImpl(); + } + + public function testBuildRegionalAccessBoundaryLookupUrl() + { + $url = $this->impl->buildRegionalAccessBoundaryLookupUrl(serviceAccountEmail: 'test@example.com'); + $this->assertEquals( + 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@example.com/allowedLocations', + $url + ); + } + + public function testLookupRegionalAccessBoundary() + { + $responseBody = + '{"locations": ["us-central1", "us-east1", "europe-west1", "asia-east1"], "enodedLocations": ""0xA30"}'; + $handler = $this->getHandler([ + new Response(200, [], $responseBody), + ]); + $result = $this->impl->lookupRegionalAccessBoundary($handler, 'default', ['Bearer xyz']); + $this->assertEquals(json_decode($responseBody, true), $result); + } + + public function testLookupRegionalAccessBoundary404() + { + $handler = $this->getHandler([ + new Response(404) + ]); + $result = $this->impl->lookupRegionalAccessBoundary($handler, 'default', ['Bearer xyz']); + $this->assertNull($result); + } + + public function testSkipLookupOutsideDefaultUniverseDomain() + { + // First call, should fetch and cache + $result1 = $this->impl->getRegionalAccessBoundary( + 'universe.domain', + fn () => throw new \Exception('Should not be called'), + 'default', + ['authorization' => ['xyz']] + ); + + $this->assertNull($result1); + } + + public function testSkipLookupIfXAllowedLocationsAreAlreadySet() + { + // First call, should fetch and cache + $result1 = $this->impl->getRegionalAccessBoundary( + 'universe.domain', + fn () => throw new \Exception('Should not be called'), + 'default', + ['authorization' => ['xyz'], ['x-allowed-locations' => 'abc']] + ); + + $this->assertNull($result1); + } + + public function testLookupIsFailOpen() + { + $mock = new MockHandler([ + new RequestException('Error Communicating with Server', new Request('GET', 'test')) + ]); + $handler = HttpHandlerFactory::build(new Client(['handler' => $mock])); + + $this->assertNull($mock->getLastRequest()); + + // First call, should fetch and cache + $result1 = $this->impl->getRegionalAccessBoundary( + GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN, + $handler, + 'default', + ['authorization' => ['xyz']] + ); + + // Ensure the request was made and the error was swallowed + $this->assertNotNull($mock->getLastRequest()); + $this->assertNull($result1); + } + + public function testLookupIsFailOpenOnConnectException() + { + $mock = new MockHandler([ + new ConnectException('Connection refused', new Request('GET', 'test')) + ]); + $handler = HttpHandlerFactory::build(new Client(['handler' => $mock])); + + $this->assertNull($mock->getLastRequest()); + + // A connection failure is not a RequestException in Guzzle 8, so it must + // also fail open rather than disrupting client authentication. + $result1 = $this->impl->getRegionalAccessBoundary( + GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN, + $handler, + 'default', + ['authorization' => ['xyz']] + ); + + // Ensure the request was made and the error was swallowed + $this->assertNotNull($mock->getLastRequest()); + $this->assertNull($result1); + } + + public function testRefreshRegionalAccessBoundaryWithCache() + { + $cache = new MemoryCacheItemPool(); + $this->impl->setCache($cache); + $responseBody = + '{"locations": ["us-central1", "us-east1", "europe-west1", "asia-east1"], "encodedLocations": "0xA30"}'; + $handler = $this->getHandler([ + new Response(200, [], $responseBody), + ]); + + // First call, should fetch and cache + $result1 = $this->impl->getRegionalAccessBoundary( + GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN, + $handler, + 'default', + ['authorization' => ['xyz']] + ); + $this->assertEquals(json_decode($responseBody, true), $result1); + + // Second call, should return from cache + $handler = $this->getHandler([ + new Response(500), // This should not be called + ]); + $result2 = $this->impl->getRegionalAccessBoundary( + GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN, + $handler, + 'default', + [] + ); + $this->assertEquals(json_decode($responseBody, true), $result2); + } + + public function testRefreshRegionalAccessBoundaryWithCacheAfterExpiry() + { + $cache = new MemoryCacheItemPool(); + $this->impl->setCache($cache); + $cachedResponseBody = + '{"locations": ["cached-locations"], "encodedLocations": "0xA30"}'; + + $cacheItem = $cache->getItem('testkeyrab'); + $cacheItem->set(json_decode($cachedResponseBody, true)); + $cacheItem->expiresAt(\DateTime::createFromFormat('U', time() + 1)); // in the future + $cache->save($cacheItem); + + // First call, should fetch from cache + $result1 = $this->impl->getRegionalAccessBoundary( + GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN, + fn () => throw new \Exception('Should not be called'), + 'default', + ['authorization' => ['xyz']] + ); + $this->assertEquals(json_decode($cachedResponseBody, true), $result1); + + // Set cache to expired + $cacheItem->expiresAt(\DateTime::createFromFormat('U', time() - 1)); // in the future + $cache->save($cacheItem); + + // Second call, should return from HTTP call + $responseBody = + '{"locations": ["noncached-locations"], "encodedLocations": "0xA30"}'; + $handler = $this->getHandler([ + new Response(200, [], $responseBody), + ]); + + $result2 = $this->impl->getRegionalAccessBoundary( + GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN, + $handler, + 'default', + ['authorization' => ['xyz']] + ); + $this->assertEquals(json_decode($responseBody, true), $result2); + } + + public function testCacheLifetime() + { + $cacheItem = $this->prophesize(CacheItemInterface::class); + $cacheItem->isHit()->shouldBeCalledOnce()->willReturn(false); + $cacheItem->set(Argument::any())->shouldBeCalledOnce()->willReturn($cacheItem->reveal()); + $cacheItem->expiresAfter(6 * 60 * 60)->shouldBeCalledOnce()->willReturn($cacheItem->reveal()); + + $cache = $this->prophesize(CacheItemPoolInterface::class); + $cache->getItem('testkeyrab') + ->shouldBeCalledTimes(2) + ->willReturn($cacheItem->reveal()); + $cache->save($cacheItem->reveal())->shouldBeCalledOnce()->willReturn(true); + + $cooldownCacheItem = $this->prophesize(CacheItemInterface::class); + $cooldownCacheItem->isHit()->shouldBeCalledOnce()->willReturn(false); + $cache->getItem('testkeyrabcooldown') + ->shouldBeCalledOnce() + ->willReturn($cooldownCacheItem->reveal()); + + $this->impl->setCache($cache->reveal()); + + $responseBody = + '{"locations": ["us-central1", "us-east1", "europe-west1", "asia-east1"], "encodedLocations": "0xA30"}'; + $handler = $this->getHandler([ + new Response(200, [], $responseBody) + ]); + // First call, should fetch and cache + $result1 = $this->impl->getRegionalAccessBoundary( + GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN, + $handler, + 'default', + ['authorization' => ['xyz']] + ); + + $this->assertNotNull($result1); + $this->assertEquals(json_decode($responseBody, true), $result1); + } + + public function testSkipLookupDuringCooldown() + { + $cache = $this->prophesize(CacheItemPoolInterface::class); + + $cacheItem = $this->prophesize(CacheItemInterface::class); + $cacheItem->isHit()->shouldBeCalledOnce()->willReturn(false); + $cache->getItem('testkeyrab') + ->shouldBeCalledOnce() + ->willReturn($cacheItem->reveal()); + + $cooldownCacheItem = $this->prophesize(CacheItemInterface::class); + $cooldownCacheItem->isHit()->shouldBeCalledOnce()->willReturn(true); + $cooldownCacheItem->get()->shouldBeCalledOnce()->willReturn(true); + + $cache->getItem('testkeyrabcooldown') + ->shouldBeCalledOnce() + ->willReturn($cooldownCacheItem->reveal()); + + $this->impl->setCache($cache->reveal()); + + // First call, should fetch and cache + $result1 = $this->impl->getRegionalAccessBoundary( + GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN, + fn () => throw new \Exception('Should not be called'), + 'default', + ['authorization' => ['xyz']] + ); + + $this->assertNull($result1); + } + + public function testSkipCooldownAfterExpiry() + { + $cache = new MemoryCacheItemPool(); + + $cacheItem = $cache->getItem('testkeyrabcooldown'); + $cacheItem->set(true); + $cacheItem->expiresAt(\DateTime::createFromFormat('U', time() - 1)); // in the past + $cache->save($cacheItem); + + $this->impl->setCache($cache); + + $result = $this->impl->getRegionalAccessBoundary( + GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN, + $this->getHandler([new Response(200, [], '{"encodedLocations": "0xA30"}')]), + 'default', + ['authorization' => ['xyz']] + ); + + $this->assertEquals(['encodedLocations' => '0xA30'], $result); + } + + public function provideCooldown() + { + $fifteenMinutes = 15 * 60; // cooldown increment + $sixHours = 6 * 60 * 60; // max cooldown + return [ + [0, $fifteenMinutes], + [1, $fifteenMinutes * 2], + [1000, $sixHours], + ]; + } + + /** + * @dataProvider provideCooldown + */ + public function testInitiateCooldown(int $attempt, int $expectedExpiry) + { + $cache = $this->prophesize(CacheItemPoolInterface::class); + + $cacheItem = $this->prophesize(CacheItemInterface::class); + $cacheItem->isHit()->shouldBeCalledOnce()->willReturn(false); + $cache->getItem('testkeyrab') + ->shouldBeCalledOnce() + ->willReturn($cacheItem->reveal()); + + $cooldownCacheItem = $this->prophesize(CacheItemInterface::class); + $cooldownCacheItem->isHit()->shouldBeCalledOnce()->willReturn(false); + $cooldownCacheItem->set(true)->shouldBeCalledOnce()->willReturn($cooldownCacheItem->reveal()); + $cooldownCacheItem->expiresAfter($expectedExpiry) + ->shouldBeCalledOnce() + ->willReturn($cooldownCacheItem->reveal()); + $cache->getItem('testkeyrabcooldown') + ->shouldBeCalledTimes(2) + ->willReturn($cooldownCacheItem->reveal()); + $cache->save($cooldownCacheItem->reveal())->shouldBeCalledOnce()->willReturn(true); + + $cooldownCacheItemAttempt = $this->prophesize(CacheItemInterface::class); + if (0 === $attempt) { + $cooldownCacheItemAttempt->isHit()->shouldBeCalledOnce()->willReturn(false); + } else { + $cooldownCacheItemAttempt->isHit()->shouldBeCalledOnce()->willReturn(true); + $cooldownCacheItemAttempt->get()->shouldBeCalledOnce()->willReturn($attempt); + } + $cooldownCacheItemAttempt->set($attempt + 1) + ->shouldBeCalledOnce() + ->willReturn($cooldownCacheItemAttempt->reveal()); + $cooldownCacheItemAttempt->expiresAfter($expectedExpiry * 2) + ->shouldBeCalledOnce() + ->willReturn($cooldownCacheItemAttempt->reveal()); + $cache->getItem('testkeyrabcooldownattempt') + ->shouldBeCalledTimes(2) + ->willReturn($cooldownCacheItemAttempt->reveal()); + $cache->save($cooldownCacheItemAttempt->reveal())->shouldBeCalledOnce()->willReturn(true); + + $this->impl->setCache($cache->reveal()); + + $mock = new MockHandler([ + new RequestException('Error Communicating with Server (1)', new Request('GET', 'test')), + ]); + $handler = HttpHandlerFactory::build(new Client(['handler' => $mock])); + + // First call, should fetch and cache + $result1 = $this->impl->getRegionalAccessBoundary( + GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN, + $handler, + 'default', + ['authorization' => ['xyz']] + ); + + $this->assertNull($result1); + } + + public function provideMalformedResponseFromAllowLocationsLookup() + { + return [ + [200, '{"locations": ["us-west1"]}'], // missing allowLocations + [200, '{"locations": ["us-west1"]'], // invalid JSON + [401, ''], // 4xx error + [500, ''], // 5xx error + ]; + } + + /** + * @dataProvider provideMalformedResponseFromAllowLocationsLookup + */ + public function testMalformedResponseFromAllowLocationsLookup(int $statusCode, string $responseBody) + { + $this->impl->setCache(new MemoryCacheItemPool()); + $handler = $this->getHandler([ + new Response($statusCode, [], $responseBody), + ]); + $result = $this->impl->getRegionalAccessBoundary( + GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN, + $handler, + 'default', + ['authorization' => ['xyz']] + ); + + $this->assertNull($result); + $this->assertTrue($this->impl->cooldownIsActive()); + } +} + +class RegionalAccessBoundaryTraitImpl +{ + use RegionalAccessBoundaryTrait { + buildRegionalAccessBoundaryLookupUrl as public; + lookupRegionalAccessBoundary as public; + getRegionalAccessBoundary as public; + } + + private $cache; + private $cacheConfig; + + public function __construct(array $config = []) + { + $this->cacheConfig = [ + 'prefix' => '', + 'lifetime' => 1000, + ]; + $this->enableRegionalAccessBoundary = true; + } + + public function getCacheKey() + { + return 'test-key'; + } + + public function setCache($cache) + { + $this->cache = $cache; + } + + public function cooldownIsActive(): bool + { + return (bool) $this->getCachedValue($this->getCacheKey() . ':rab:cooldown'); + } +} diff --git a/Auth/tests/Credentials/ServiceAccountCredentialsTest.php b/Auth/tests/Credentials/ServiceAccountCredentialsTest.php new file mode 100644 index 000000000000..7a7ba1696ed6 --- /dev/null +++ b/Auth/tests/Credentials/ServiceAccountCredentialsTest.php @@ -0,0 +1,474 @@ + 'key123', + 'private_key' => 'privatekey', + 'client_email' => 'test@example.com', + 'client_id' => 'client123', + 'type' => 'service_account', + 'project_id' => 'example_project', + 'private_key' => file_get_contents(__DIR__ . '/../fixtures/fixtures1/private.pem'), + ]; + } + + public function testShouldBeTheSameAsOAuth2WithTheSameScope() + { + $testJson = $this->createTestJson(); + $scope = ['scope/1', 'scope/2']; + $sa = new ServiceAccountCredentials( + $scope, + $testJson + ); + $o = new OAuth2(['scope' => $scope]); + $this->assertSame( + $testJson['client_email'] . '.' . implode(' ', $scope), + $sa->getCacheKey() + ); + } + + public function testShouldBeTheSameAsOAuth2WithTheSameScopeWithSub() + { + $testJson = $this->createTestJson(); + $scope = ['scope/1', 'scope/2']; + $sub = 'sub123'; + $sa = new ServiceAccountCredentials( + $scope, + $testJson, + $sub + ); + $o = new OAuth2(['scope' => $scope]); + $this->assertSame( + $testJson['client_email'] . '.' . implode(' ', $scope) . '.' . $sub, + $sa->getCacheKey() + ); + } + + public function testShouldBeTheSameAsOAuth2WithTheSameScopeWithSubAddedLater() + { + $testJson = $this->createTestJson(); + $scope = ['scope/1', 'scope/2']; + $sub = 'sub123'; + $sa = new ServiceAccountCredentials( + $scope, + $testJson, + null + ); + $sa->setSub($sub); + + $o = new OAuth2(['scope' => $scope]); + $this->assertSame( + $testJson['client_email'] . '.' . implode(' ', $scope) . '.' . $sub, + $sa->getCacheKey() + ); + } + + public function testShouldFailIfScopeIsNotAValidType() + { + $this->expectexception(InvalidArgumentException::class); + + $testJson = $this->createTestJson(); + $notAnArrayOrString = new \stdClass(); + $sa = new ServiceAccountCredentials( + $notAnArrayOrString, + $testJson + ); + } + + public function testShouldFailIfJsonDoesNotHaveClientEmail() + { + $this->expectException(InvalidArgumentException::class); + + $testJson = $this->createTestJson(); + unset($testJson['client_email']); + $scope = ['scope/1', 'scope/2']; + $sa = new ServiceAccountCredentials( + $scope, + $testJson + ); + } + + public function testShouldFailIfJsonDoesNotHavePrivateKey() + { + $this->expectException(InvalidArgumentException::class); + + $testJson = $this->createTestJson(); + unset($testJson['private_key']); + $scope = ['scope/1', 'scope/2']; + $sa = new ServiceAccountCredentials( + $scope, + $testJson + ); + } + + public function testFailsToInitalizeFromANonExistentFile() + { + $this->expectException(InvalidArgumentException::class); + + $keyFile = __DIR__ . '/../fixtures/fixtures1/does-not-exist-private.json'; + new ServiceAccountCredentials('scope/1', $keyFile); + } + + public function testInitalizeFromAFile() + { + $keyFile = __DIR__ . '/../fixtures/fixtures1/private.json'; + $this->assertNotNull( + new ServiceAccountCredentials('scope/1', $keyFile) + ); + } + + public function testFailsToInitializeFromInvalidJsonData() + { + $this->expectException(LogicException::class); + + $tmp = tmpfile(); + fwrite($tmp, '{'); + + $path = stream_get_meta_data($tmp)['uri']; + + try { + new ServiceAccountCredentials('scope/1', $path); + } catch (\Exception $e) { + fclose($tmp); + throw $e; + } + } + + public function testIsNullIfEnvVarIsNotSet() + { + $this->assertNull(ServiceAccountCredentials::fromEnv()); + } + + /** @runInSeparateProcess */ + public function testFailsIfEnvSpecifiesNonExistentFile() + { + $this->expectException(DomainException::class); + $keyFile = __DIR__ . '/../fixtures/fixtures1/does-not-exist-private.json'; + putenv(ServiceAccountCredentials::ENV_VAR . '=' . $keyFile); + ApplicationDefaultCredentials::getCredentials('a scope'); + } + + /** @runInSeparateProcess */ + public function testSucceedIfFileExists() + { + $keyFile = __DIR__ . '/../fixtures/fixtures1/private.json'; + putenv(ServiceAccountCredentials::ENV_VAR . '=' . $keyFile); + $this->assertNotNull(ApplicationDefaultCredentials::getCredentials('a scope')); + } + + /** @runInSeparateProcess */ + public function testIsNullIfFileDoesNotExist() + { + $this->setHomeEnv(__DIR__ . '/../not_exists_fixtures'); + $this->assertNull( + ServiceAccountCredentials::fromWellKnownFile() + ); + } + + /** @runInSeparateProcess */ + public function testSucceedIfFileIsPresent() + { + $this->setHomeEnv(__DIR__ . '/../fixtures/fixtures1'); + $this->assertNotNull( + ApplicationDefaultCredentials::getCredentials('a scope') + ); + } + + public function testFailsOnClientErrors() + { + $this->expectException(\GuzzleHttp\Exception\ClientException::class); + + $testJson = $this->createTestJson(); + $scope = ['scope/1', 'scope/2']; + $httpHandler = $this->getHandler([ + new Response(400), + ]); + $sa = new ServiceAccountCredentials( + $scope, + $testJson + ); + $sa->fetchAuthToken($httpHandler); + } + + public function testFailsOnServerErrors() + { + $this->expectException(\GuzzleHttp\Exception\ServerException::class); + + $testJson = $this->createTestJson(); + $scope = ['scope/1', 'scope/2']; + $httpHandler = $this->getHandler([ + new Response(500), + ]); + $sa = new ServiceAccountCredentials( + $scope, + $testJson + ); + $sa->fetchAuthToken($httpHandler); + } + + public function testCanFetchCredsOK() + { + $testJson = $this->createTestJson(); + $testJsonText = json_encode($testJson); + $scope = ['scope/1', 'scope/2']; + $httpHandler = $this->getHandler([ + new Response(200, [], Utils::streamFor($testJsonText)), + ]); + $sa = new ServiceAccountCredentials( + $scope, + $testJson + ); + $tokens = $sa->fetchAuthToken($httpHandler); + $this->assertEquals($testJson, $tokens); + } + + public function testUpdateMetadataFunc() + { + $testJson = $this->createTestJson(); + $scope = ['scope/1', 'scope/2']; + $access_token = 'accessToken123'; + $responseText = json_encode(['access_token' => $access_token]); + $httpHandler = $this->getHandler([ + new Response(200, [], Utils::streamFor($responseText)), + ]); + $sa = new ServiceAccountCredentials( + $scope, + $testJson + ); + $update_metadata = $sa->getUpdateMetadataFunc(); + $this->assertTrue(is_callable($update_metadata)); + + $actual_metadata = call_user_func( + $update_metadata, + $metadata = ['foo' => 'bar'], + $authUri = null, + $httpHandler + ); + $this->assertArrayHasKey( + CredentialsLoader::AUTH_METADATA_KEY, + $actual_metadata + ); + $this->assertEquals( + $actual_metadata[CredentialsLoader::AUTH_METADATA_KEY], + ['Bearer ' . $access_token] + ); + } + + public function testShouldBeIdTokenWhenTargetAudienceIsSet() + { + $testJson = $this->createTestJson(); + $expectedToken = ['id_token' => 'idtoken12345']; + $timesCalled = 0; + $httpHandler = function ($request) use (&$timesCalled, $expectedToken) { + $timesCalled++; + parse_str($request->getBody(), $post); + $this->assertArrayHasKey('assertion', $post); + list($header, $payload, $sig) = explode('.', $post['assertion']); + $jwtParams = json_decode(base64_decode($payload), true); + $this->assertArrayHasKey('target_audience', $jwtParams); + $this->assertEquals('a target audience', $jwtParams['target_audience']); + + return new Psr7\Response(200, [], Utils::streamFor(json_encode($expectedToken))); + }; + $sa = new ServiceAccountCredentials(null, $testJson, null, 'a target audience'); + $this->assertEquals($expectedToken, $sa->fetchAuthToken($httpHandler)); + $this->assertEquals(1, $timesCalled); + } + + public function testShouldUseIamWhenTargetAudienceAndUniverseDomainIsSet() + { + $testJson = $this->createTestJson(); + $testJson['universe_domain'] = 'abc.xyz'; + + $timesCalled = 0; + $httpHandler = function (Request $request) use (&$timesCalled) { + $timesCalled++; + + // Verify Request + $this->assertStringContainsString(':generateIdToken', $request->getUri()); + $json = json_decode($request->getBody(), true); + $this->assertArrayHasKey('audience', $json); + $this->assertEquals('a target audience', $json['audience']); + + // Verify JWT Bearer Token + $jwt = str_replace('Bearer ', '', $request->getHeaderLine('Authorization')); + list($header, $payload, $sig) = explode('.', $jwt); + $jwtParams = json_decode(base64_decode($payload), true); + $this->assertArrayHasKey('iss', $jwtParams); + $this->assertEquals('test@example.com', $jwtParams['iss']); + + // Verify header contains the auth headers + $parts = explode(' ', $request->getHeaderLine('x-goog-api-client')); + $this->assertContains('auth-request-type/it', $parts); + + // return expected IAM ID token response + return new Psr7\Response(200, [], json_encode(['token' => 'idtoken12345'])); + }; + $sa = new ServiceAccountCredentials(null, $testJson, null, 'a target audience'); + $this->assertEquals('idtoken12345', $sa->fetchAuthToken($httpHandler)['id_token']); + $this->assertEquals(1, $timesCalled); + } + + public function testShouldBeOAuthRequestWhenSubIsSet() + { + $testJson = $this->createTestJson(); + $sub = 'sub12345'; + $timesCalled = 0; + $httpHandler = function ($request) use (&$timesCalled, $sub) { + $timesCalled++; + parse_str($request->getBody(), $post); + $this->assertArrayHasKey('assertion', $post); + list($header, $payload, $sig) = explode('.', $post['assertion']); + $jwtParams = json_decode(base64_decode($payload), true); + $this->assertArrayHasKey('sub', $jwtParams); + $this->assertEquals($sub, $jwtParams['sub']); + + return new Psr7\Response(200, [], Utils::streamFor(json_encode([ + 'access_token' => 'token123' + ]))); + }; + $sa = new ServiceAccountCredentials(null, $testJson, $sub); + $this->assertEquals('token123', $sa->fetchAuthToken($httpHandler)['access_token']); + $this->assertEquals(1, $timesCalled); + } + + public function testSettingBothScopeAndTargetAudienceThrowsException() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Scope and targetAudience cannot both be supplied'); + + $testJson = $this->createTestJson(); + $sa = new ServiceAccountCredentials( + 'a-scope', + $testJson, + null, + 'a-target-audience' + ); + } + + public function testDomainWideDelegationOutsideGduThrowsException() + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage( + 'Service Account subject is configured for the credential. Domain-wide ' . + 'delegation is not supported in universes other than googleapis.com' + ); + $testJson = $this->createTestJson() + ['universe_domain' => 'abc.xyz']; + $sub = 'sub123'; + $sa = new ServiceAccountCredentials( + null, + $testJson, + $sub + ); + + $sa->fetchAuthToken(); + } + + public function testReturnsClientEmail() + { + $testJson = $this->createTestJson(); + $sa = new ServiceAccountCredentials('scope/1', $testJson); + $this->assertEquals($testJson['client_email'], $sa->getClientName()); + } + + public function testReturnsPrivateKey() + { + $testJson = $this->createTestJson(); + $sa = new ServiceAccountCredentials('scope/1', $testJson); + $this->assertEquals($testJson['private_key'], $sa->getPrivateKey()); + } + + public function testGetProjectId() + { + $testJson = $this->createTestJson(); + $sa = new ServiceAccountCredentials('scope/1', $testJson); + $this->assertEquals($testJson['project_id'], $sa->getProjectId()); + } + + public function testGetQuotaProject() + { + $keyFile = __DIR__ . '/../fixtures/fixtures1/private.json'; + $sa = new ServiceAccountCredentials('scope/1', $keyFile); + $this->assertEquals('test_quota_project', $sa->getQuotaProject()); + } + + public function testUpdateMetadataWithRegionalAccessBoundary() + { + $httpHandler = $this->getHandler([ + new Response(200, [], '{"access_token": "source-token", "expires_in": 3600}'), + new Response(200, [], '{"locations": [], "encodedLocations": "foo"}'), + ]); + + $jsonKey = [ + 'type' => 'service_account', + 'private_key' => file_get_contents(__DIR__ . '/../fixtures/fixtures1/private.pem'), + 'client_email' => 'test@example.com', + ]; + $serviceAccountCreds = new ServiceAccountCredentials( + 'a-scope', + $jsonKey, + enableRegionalAccessBoundary: true + ); + + $metadata = $serviceAccountCreds->updateMetadata([], null, $httpHandler); + + $this->assertArrayHasKey('x-allowed-locations', $metadata); + $this->assertEquals('foo', $metadata['x-allowed-locations']); + } + + public function testUpdateMetadataWithRegionalAccessBoundarySuppressedWithUniverseDomain() + { + $jsonKey = [ + 'type' => 'service_account', + 'private_key' => file_get_contents(__DIR__ . '/../fixtures/fixtures1/private.pem'), + 'client_email' => 'test@example.com', + 'universe_domain' => 'foo.com', + ]; + $serviceAccountCreds = new ServiceAccountCredentials( + 'a-scope', + $jsonKey, + enableRegionalAccessBoundary: true + ); + + $metadata = $serviceAccountCreds->updateMetadata([]); + + $this->assertArrayNotHasKey('x-allowed-locations', $metadata); + } +} diff --git a/Auth/tests/Credentials/ServiceAccountJwtAccessCredentialsTest.php b/Auth/tests/Credentials/ServiceAccountJwtAccessCredentialsTest.php new file mode 100644 index 000000000000..51f26c8c830a --- /dev/null +++ b/Auth/tests/Credentials/ServiceAccountJwtAccessCredentialsTest.php @@ -0,0 +1,583 @@ + 'key123', + 'private_key' => 'privatekey', + 'client_email' => 'test@example.com', + 'client_id' => 'client123', + 'type' => 'service_account', + 'project_id' => 'example_project', + 'private_key' => file_get_contents(__DIR__ . '/../fixtures/fixtures1/private.pem'), + ]; + } + + public function testFailsToInitalizeFromANonExistentFile() + { + $this->expectException(InvalidArgumentException::class); + + $keyFile = __DIR__ . '/../fixtures/fixtures1/does-not-exist-private.json'; + new ServiceAccountJwtAccessCredentials($keyFile); + } + + public function testInitalizeFromAFile() + { + $keyFile = __DIR__ . '/../fixtures/fixtures1/private.json'; + $this->assertNotNull( + new ServiceAccountJwtAccessCredentials($keyFile) + ); + } + + public function testFailsToInitializeFromInvalidJsonData() + { + $this->expectException(LogicException::class); + $tmp = tmpfile(); + fwrite($tmp, '{'); + + $path = stream_get_meta_data($tmp)['uri']; + + try { + new ServiceAccountJwtAccessCredentials($path); + } catch (\Exception $e) { + fclose($tmp); + throw $e; + } + } + + public function testFailsOnMissingClientEmail() + { + $this->expectException(InvalidArgumentException::class); + + $testJson = $this->createTestJson(); + unset($testJson['client_email']); + $sa = new ServiceAccountJwtAccessCredentials( + $testJson + ); + } + + public function testFailsOnMissingPrivateKey() + { + $this->expectException(InvalidArgumentException::class); + + $testJson = $this->createTestJson(); + unset($testJson['private_key']); + $sa = new ServiceAccountJwtAccessCredentials( + $testJson + ); + } + + public function testFailsWithBothAudienceAndScope() + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Cannot sign both audience and scope in JwtAccess'); + + $scope = 'scope/1'; + $audience = 'https://example.com/service'; + $testJson = $this->createTestJson(); + $sa = new ServiceAccountJwtAccessCredentials($testJson, $scope); + $sa->updateMetadata([], $audience); + } + + public function testCanInitializeFromJson() + { + $testJson = $this->createTestJson(); + $sa = new ServiceAccountJwtAccessCredentials( + $testJson + ); + $this->assertNotNull($sa); + } + + public function testNoOpOnFetchAuthToken() + { + $testJson = $this->createTestJson(); + $sa = new ServiceAccountJwtAccessCredentials( + $testJson + ); + $this->assertNotNull($sa); + + $httpHandler = $this->getHandler([ + new Response(200), + ]); + $result = $sa->fetchAuthToken($httpHandler); // authUri has not been set + $this->assertNull($result); + } + + public function testAuthUriIsNotSet() + { + $testJson = $this->createTestJson(); + $sa = new ServiceAccountJwtAccessCredentials( + $testJson + ); + $this->assertNotNull($sa); + + $update_metadata = $sa->getUpdateMetadataFunc(); + $this->assertTrue(is_callable($update_metadata)); + + $actual_metadata = call_user_func( + $update_metadata, + $metadata = ['foo' => 'bar'], + $authUri = null + ); + $this->assertArrayNotHasKey( + CredentialsLoader::AUTH_METADATA_KEY, + $actual_metadata + ); + } + + public function testGetLastReceivedToken() + { + $testJson = $this->createTestJson(); + $sa = new ServiceAccountJwtAccessCredentials($testJson); + $token = $sa->fetchAuthToken(); + $this->assertEquals($token, $sa->getLastReceivedToken()); + } + + public function testUpdateMetadataFunc() + { + $testJson = $this->createTestJson(); + $sa = new ServiceAccountJwtAccessCredentials( + $testJson + ); + $this->assertNotNull($sa); + + $update_metadata = $sa->getUpdateMetadataFunc(); + $this->assertTrue(is_callable($update_metadata)); + + $actual_metadata = call_user_func( + $update_metadata, + $metadata = ['foo' => 'bar'], + $authUri = 'https://example.com/service' + ); + $this->assertArrayHasKey( + CredentialsLoader::AUTH_METADATA_KEY, + $actual_metadata + ); + + $authorization = $actual_metadata[CredentialsLoader::AUTH_METADATA_KEY]; + $this->assertTrue(is_array($authorization)); + + $bearer_token = current($authorization); + $this->assertTrue(is_string($bearer_token)); + $this->assertEquals(0, strpos($bearer_token, 'Bearer ')); + $this->assertGreaterThan(30, strlen($bearer_token)); + + $actual_metadata2 = call_user_func( + $update_metadata, + $metadata = ['foo' => 'bar'], + $authUri = 'https://example.com/anotherService' + ); + $this->assertArrayHasKey( + CredentialsLoader::AUTH_METADATA_KEY, + $actual_metadata2 + ); + + $authorization2 = $actual_metadata2[CredentialsLoader::AUTH_METADATA_KEY]; + $this->assertTrue(is_array($authorization2)); + + $bearer_token2 = current($authorization2); + $this->assertTrue(is_string($bearer_token2)); + $this->assertEquals(0, strpos($bearer_token2, 'Bearer ')); + $this->assertGreaterThan(30, strlen($bearer_token2)); + $this->assertNotEquals($bearer_token2, $bearer_token); + } + + public function testNoScopeUseJwtAccess() + { + $testJson = $this->createTestJson(); + // no scope, jwt access should be used, no outbound + // call should be made + $scope = null; + $sa = new ServiceAccountCredentials( + $scope, + $testJson + ); + $this->assertNotNull($sa); + + $update_metadata = $sa->getUpdateMetadataFunc(); + $this->assertTrue(is_callable($update_metadata)); + + $actual_metadata = call_user_func( + $update_metadata, + $metadata = ['foo' => 'bar'], + $authUri = 'https://example.com/service' + ); + $this->assertArrayHasKey( + CredentialsLoader::AUTH_METADATA_KEY, + $actual_metadata + ); + + $authorization = $actual_metadata[CredentialsLoader::AUTH_METADATA_KEY]; + $this->assertTrue(is_array($authorization)); + + $bearer_token = current($authorization); + $this->assertTrue(is_string($bearer_token)); + $this->assertEquals(0, strpos($bearer_token, 'Bearer ')); + $this->assertGreaterThan(30, strlen($bearer_token)); + } + + public function testUpdateMetadataWithScopeAndUseJwtAccessWithScopeParameter() + { + $testJson = $this->createTestJson(); + // jwt access should be used even when scopes are supplied, no outbound + // call should be made + $scope = 'scope1 scope2'; + $sa = new ServiceAccountCredentials( + $scope, + $testJson + ); + $sa->useJwtAccessWithScope(); + + $actual_metadata = $sa->updateMetadata( + $metadata = ['foo' => 'bar'], + $authUri = 'https://example.com/service' + ); + + $this->assertArrayHasKey( + CredentialsLoader::AUTH_METADATA_KEY, + $actual_metadata + ); + + $authorization = $actual_metadata[CredentialsLoader::AUTH_METADATA_KEY]; + $this->assertTrue(is_array($authorization)); + + $bearer_token = current($authorization); + $this->assertTrue(is_string($bearer_token)); + $this->assertEquals(0, strpos($bearer_token, 'Bearer ')); + + // Ensure scopes are signed inside + $token = substr($bearer_token, strlen('Bearer ')); + $this->assertEquals(2, substr_count($token, '.')); + list($header, $payload, $sig) = explode('.', $bearer_token); + $json = json_decode(base64_decode($payload), true); + $this->assertTrue(is_array($json)); + $this->assertArrayHasKey('scope', $json); + $this->assertEquals($json['scope'], $scope); + } + + public function testUpdateMetadataWithScopeAndUseJwtAccessWithScopeParameterAndArrayScopes() + { + $testJson = $this->createTestJson(); + // jwt access should be used even when scopes are supplied, no outbound + // call should be made + $scope = ['scope1', 'scope2']; + $sa = new ServiceAccountCredentials( + $scope, + $testJson + ); + $sa->useJwtAccessWithScope(); + + $actual_metadata = $sa->updateMetadata( + $metadata = ['foo' => 'bar'], + $authUri = 'https://example.com/service' + ); + + $this->assertArrayHasKey( + CredentialsLoader::AUTH_METADATA_KEY, + $actual_metadata + ); + + $authorization = $actual_metadata[CredentialsLoader::AUTH_METADATA_KEY]; + $this->assertTrue(is_array($authorization)); + + $bearer_token = current($authorization); + $this->assertTrue(is_string($bearer_token)); + $this->assertEquals(0, strpos($bearer_token, 'Bearer ')); + + // Ensure scopes are signed inside + $token = substr($bearer_token, strlen('Bearer ')); + $this->assertEquals(2, substr_count($token, '.')); + list($header, $payload, $sig) = explode('.', $bearer_token); + $json = json_decode(base64_decode($payload), true); + $this->assertTrue(is_array($json)); + $this->assertArrayHasKey('scope', $json); + $this->assertEquals($json['scope'], implode(' ', $scope)); + + // Test last received token + $cachedToken = $sa->getLastReceivedToken(); + $this->assertTrue(is_array($cachedToken)); + $this->assertArrayHasKey('access_token', $cachedToken); + $this->assertEquals($token, $cachedToken['access_token']); + } + + public function testFetchAuthTokenWithScopeAndUseJwtAccessWithScopeParameter() + { + $testJson = $this->createTestJson(); + // jwt access should be used even when scopes are supplied, no outbound + // call should be made + $scope = 'scope1 scope2'; + $sa = new ServiceAccountCredentials( + $scope, + $testJson + ); + $sa->useJwtAccessWithScope(); + + $access_token = $sa->fetchAuthToken(); + $this->assertTrue(is_array($access_token)); + $this->assertArrayHasKey('access_token', $access_token); + $token = $access_token['access_token']; + + // Ensure scopes are signed inside + $this->assertEquals(2, substr_count($token, '.')); + list($header, $payload, $sig) = explode('.', $token); + $json = json_decode(base64_decode($payload), true); + $this->assertTrue(is_array($json)); + $this->assertArrayHasKey('scope', $json); + $this->assertEquals($json['scope'], $scope); + } + + public function testFetchAuthTokenWithScopeAndUseJwtAccessWithScopeParameterAndArrayScopes() + { + $testJson = $this->createTestJson(); + // jwt access should be used even when scopes are supplied, no outbound + // call should be made + $scope = ['scope1', 'scope2']; + $sa = new ServiceAccountCredentials( + $scope, + $testJson + ); + $sa->useJwtAccessWithScope(); + + $access_token = $sa->fetchAuthToken(); + $this->assertTrue(is_array($access_token)); + $this->assertArrayHasKey('access_token', $access_token); + $token = $access_token['access_token']; + + // Ensure scopes are signed inside + $this->assertEquals(2, substr_count($token, '.')); + list($header, $payload, $sig) = explode('.', $token); + $json = json_decode(base64_decode($payload), true); + $this->assertTrue(is_array($json)); + $this->assertArrayHasKey('scope', $json); + $this->assertEquals($json['scope'], implode(' ', $scope)); + + // Test last received token + $cachedToken = $sa->getLastReceivedToken(); + $this->assertTrue(is_array($cachedToken)); + $this->assertArrayHasKey('access_token', $cachedToken); + $this->assertEquals($token, $cachedToken['access_token']); + } + + /** @runInSeparateProcess */ + public function testAccessFromApplicationDefault() + { + $keyFile = __DIR__ . '/../fixtures/fixtures3/service_account_credentials.json'; + putenv(ServiceAccountCredentials::ENV_VAR . '=' . $keyFile); + $creds = ApplicationDefaultCredentials::getCredentials( + null, // $scope + null, // $httpHandler + null, // $cacheConfig + null, // $cache + null, // $quotaProject + 'a default scope' // $defaultScope + ); + $authUri = 'https://example.com/service'; + + $metadata = $creds->updateMetadata(['foo' => 'bar'], $authUri); + + $this->assertArrayHasKey('authorization', $metadata); + $token = str_replace('Bearer ', '', $metadata['authorization'][0]); + $key = file_get_contents(__DIR__ . '/../fixtures/fixtures3/key.pub'); + $result = JWT::decode($token, new Key($key, 'RS256')); + + $this->assertEquals($authUri, $result->aud); + } + + public function testNoScopeAndNoAuthUri() + { + $testJson = $this->createTestJson(); + // no scope, jwt access should be used, no outbound + // call should be made + $scope = null; + $sa = new ServiceAccountCredentials( + $scope, + $testJson + ); + $this->assertNotNull($sa); + + $update_metadata = $sa->getUpdateMetadataFunc(); + $this->assertTrue(is_callable($update_metadata)); + + $actual_metadata = call_user_func( + $update_metadata, + $metadata = ['foo' => 'bar'], + $authUri = null + ); + // no access_token is added to the metadata hash + // but also, no error should be thrown + $this->assertTrue(is_array($actual_metadata)); + $this->assertArrayNotHasKey( + CredentialsLoader::AUTH_METADATA_KEY, + $actual_metadata + ); + } + + public function testUpdateMetadataJwtAccess() + { + $testJson = $this->createTestJson(); + // no scope, jwt access should be used, no outbound + // call should be made + $scope = null; + $sa = new ServiceAccountCredentials( + $scope, + $testJson + ); + $this->assertNotNull($sa); + $metadata = $sa->updateMetadata( + ['foo' => 'bar'], + 'https://example.com/service' + ); + $this->assertArrayHasKey( + CredentialsLoader::AUTH_METADATA_KEY, + $metadata + ); + + $authorization = $metadata[CredentialsLoader::AUTH_METADATA_KEY]; + $this->assertTrue(is_array($authorization)); + + $bearerToken = current($authorization); + $this->assertTrue(is_string($bearerToken)); + $this->assertEquals(0, strpos($bearerToken, 'Bearer ')); + $token = str_replace('Bearer ', '', $bearerToken); + + $lastReceivedToken = $sa->getLastReceivedToken(); + $this->assertArrayHasKey('access_token', $lastReceivedToken); + $this->assertEquals($token, $lastReceivedToken['access_token']); + } + + public function testShouldBeTheSameAsOAuth2WithTheSameScope() + { + $testJson = $this->createTestJson(); + $scope = ['scope/1', 'scope/2']; + $sa = new ServiceAccountJwtAccessCredentials($testJson, $scope); + + $expectedKey = $testJson['client_email'] . '.' . implode(' ', $scope); + $this->assertEquals($expectedKey, $sa->getCacheKey()); + } + + public function testReturnsClientEmail() + { + $testJson = $this->createTestJson(); + $sa = new ServiceAccountJwtAccessCredentials($testJson); + $this->assertEquals($testJson['client_email'], $sa->getClientName()); + } + + public function testReturnsPrivateKey() + { + $testJson = $this->createTestJson(); + $sa = new ServiceAccountJwtAccessCredentials($testJson); + $this->assertEquals($testJson['private_key'], $sa->getPrivateKey()); + } + + public function testGetProjectId() + { + $testJson = $this->createTestJson(); + $sa = new ServiceAccountJwtAccessCredentials($testJson); + $this->assertEquals($testJson['project_id'], $sa->getProjectId()); + } + + public function testGetQuotaProject() + { + $keyFile = __DIR__ . '/../fixtures/fixtures1/private.json'; + $sa = new ServiceAccountJwtAccessCredentials($keyFile); + $this->assertEquals('test_quota_project', $sa->getQuotaProject()); + } + + public function testUpdateMetadataWithUniverseDomainAlwaysUsesJwtAccess() + { + $testJson = $this->createTestJson() + ['universe_domain' => 'abc.xyz']; + // jwt access should always be used when the universe domain is set, + // even if scopes are supplied but useJwtAccessWithScope is false + $scope = ['scope1', 'scope2']; + $sa = new ServiceAccountCredentials( + $scope, + $testJson + ); + + $metadata = $sa->updateMetadata( + ['foo' => 'bar'], + 'https://example.com/service' + ); + + $this->assertArrayHasKey( + CredentialsLoader::AUTH_METADATA_KEY, + $metadata + ); + + $authorization = $metadata[CredentialsLoader::AUTH_METADATA_KEY]; + $this->assertTrue(is_array($authorization)); + + $token = current($authorization); + $this->assertTrue(is_string($token)); + $this->assertEquals(0, strpos($token, 'Bearer ')); + + // Ensure token is a self-signed JWT + $token = substr($token, strlen('Bearer ')); + $this->assertEquals(2, substr_count($token, '.')); + list($header, $payload, $sig) = explode('.', $token); + $json = json_decode(base64_decode($payload), true); + $this->assertTrue(is_array($json)); + // Ensure scopes exist + $this->assertArrayHasKey('scope', $json); + $this->assertEquals($json['scope'], implode(' ', $scope)); + } + + public function testUpdateMetadataWithRegionalAccessBoundary() + { + $httpHandler = $this->getHandler([ + new Response(200, [], '{"locations": [], "encodedLocations": "foo"}'), + ]); + + $jsonKey = [ + 'type' => 'service_account', + 'private_key' => file_get_contents(__DIR__ . '/../fixtures/fixtures1/private.pem'), + 'client_email' => 'test@example.com', + ]; + $serviceAccountCreds = new ServiceAccountCredentials( + 'a-scope', + $jsonKey, + enableRegionalAccessBoundary: true + ); + $serviceAccountCreds->useJwtAccessWithScope(); + + $metadata = $serviceAccountCreds->updateMetadata([], null, $httpHandler); + + $this->assertArrayHasKey('x-allowed-locations', $metadata); + $this->assertEquals('foo', $metadata['x-allowed-locations']); + } +} diff --git a/Auth/tests/Credentials/UserRefreshCredentialsTest.php b/Auth/tests/Credentials/UserRefreshCredentialsTest.php new file mode 100644 index 000000000000..26dca2b39e19 --- /dev/null +++ b/Auth/tests/Credentials/UserRefreshCredentialsTest.php @@ -0,0 +1,293 @@ +originalHome = getenv('HOME'); + } + + protected function tearDown(): void + { + putenv(UserRefreshCredentials::ENV_VAR); // removes it from + if ($this->originalHome != getenv('HOME')) { + $this->setHomeEnv($this->originalHome); + } + } + + // Creates a standard JSON auth object for testing. + private function createTestJson() + { + return [ + 'client_id' => 'client123', + 'client_secret' => 'clientSecret123', + 'refresh_token' => 'refreshToken123', + 'type' => 'authorized_user', + ]; + } + + public function testShouldBeTheSameAsOAuth2WithTheSameScope() + { + $testJson = $this->createTestJson(); + $scope = ['scope/1', 'scope/2']; + $sa = new UserRefreshCredentials( + $scope, + $testJson + ); + $o = new OAuth2(['scope' => $scope]); + $this->assertSame( + $testJson['client_id'] . '.' . implode(' ', $scope), + $sa->getCacheKey() + ); + } + + public function testShouldFailIfScopeIsNotAValidType() + { + $this->expectException(InvalidArgumentException::class); + $testJson = $this->createTestJson(); + $notAnArrayOrString = new \stdClass(); + $sa = new UserRefreshCredentials( + $notAnArrayOrString, + $testJson + ); + } + + public function testShouldFailIfJsonDoesNotHaveClientSecret() + { + $this->expectException(InvalidArgumentException::class); + $testJson = $this->createTestJson(); + unset($testJson['client_secret']); + $scope = ['scope/1', 'scope/2']; + $sa = new UserRefreshCredentials( + $scope, + $testJson + ); + } + + public function testShouldFailIfJsonDoesNotHaveRefreshToken() + { + $this->expectException(InvalidArgumentException::class); + $testJson = $this->createTestJson(); + unset($testJson['refresh_token']); + $scope = ['scope/1', 'scope/2']; + $sa = new UserRefreshCredentials( + $scope, + $testJson + ); + } + + public function testShouldFailIfJsonDoesNotHaveClientId() + { + $this->expectException(InvalidArgumentException::class); + $testJson = $this->createTestJson(); + unset($testJson['client_id']); + $scope = ['scope/1', 'scope/2']; + $sa = new UserRefreshCredentials( + $scope, + $testJson + ); + } + + public function testFailsToInitalizeFromANonExistentFile() + { + $this->expectException(InvalidArgumentException::class); + $keyFile = __DIR__ . '/../fixtures/does-not-exist-private.json'; + new UserRefreshCredentials('scope/1', $keyFile); + } + + public function testInitalizeFromAFile() + { + $keyFile = __DIR__ . '/../fixtures/fixtures2' . '/private.json'; + $this->assertNotNull( + new UserRefreshCredentials('scope/1', $keyFile) + ); + } + + public function testFailsToInitializeFromInvalidJsonData() + { + $this->expectException(LogicException::class); + + $tmp = tmpfile(); + fwrite($tmp, '{'); + + $path = stream_get_meta_data($tmp)['uri']; + + try { + new UserRefreshCredentials('scope/1', $path); + } catch (\Exception $e) { + fclose($tmp); + throw $e; + } + } + + public function testValid3LOauthCreds() + { + $keyFile = __DIR__ . '/../fixtures/fixtures2/valid_oauth_creds.json'; + $this->assertNotNull( + new UserRefreshCredentials('scope/1', $keyFile) + ); + } + + public function testIsNullIfEnvVarIsNotSet() + { + $this->assertNull(UserRefreshCredentials::fromEnv('a scope')); + } + + public function testFailsIfEnvSpecifiesNonExistentFile() + { + $this->expectException(DomainException::class); + $keyFile = __DIR__ . '/../fixtures/does-not-exist-private.json'; + putenv(UserRefreshCredentials::ENV_VAR . '=' . $keyFile); + UserRefreshCredentials::fromEnv('a scope'); + } + + public function testSucceedIfFileExists() + { + $keyFile = __DIR__ . '/../fixtures/fixtures2/private.json'; + putenv(UserRefreshCredentials::ENV_VAR . '=' . $keyFile); + $this->assertNotNull(ApplicationDefaultCredentials::getCredentials('a scope')); + } + + public function testIsNullIfFileDoesNotExist() + { + $this->setHomeEnv(__DIR__ . '/../not_exist_fixtures'); + $this->assertNull( + UserRefreshCredentials::fromWellKnownFile('a scope') + ); + } + + public function testSucceedIfFileIsPresent() + { + $this->setHomeEnv(__DIR__ . '/../fixtures/fixtures2'); + $this->assertNotNull( + ApplicationDefaultCredentials::getCredentials('a scope') + ); + } + + public function testFailsOnClientErrors() + { + $this->expectException(\GuzzleHttp\Exception\ClientException::class); + $testJson = $this->createTestJson(); + $scope = ['scope/1', 'scope/2']; + $httpHandler = $this->getHandler([ + new Response(400), + ]); + $sa = new UserRefreshCredentials( + $scope, + $testJson + ); + $sa->fetchAuthToken($httpHandler); + } + + public function testFailsOnServerErrors() + { + $this->expectException(\GuzzleHttp\Exception\ServerException::class); + $testJson = $this->createTestJson(); + $scope = ['scope/1', 'scope/2']; + $httpHandler = $this->getHandler([ + new Response(500), + ]); + $sa = new UserRefreshCredentials( + $scope, + $testJson + ); + $sa->fetchAuthToken($httpHandler); + } + + public function testCanFetchCredsOK() + { + $testJson = $this->createTestJson(); + $testJsonText = json_encode($testJson); + $scope = ['scope/1', 'scope/2']; + $httpHandler = $this->getHandler([ + new Response(200, [], Utils::streamFor($testJsonText)), + ]); + $sa = new UserRefreshCredentials( + $scope, + $testJson + ); + $tokens = $sa->fetchAuthToken($httpHandler); + $this->assertEquals($testJson, $tokens); + } + + public function testGetGrantedScope() + { + $responseJson = json_encode(['scope' => 'scope/1 scope/2']); + $httpHandler = $this->getHandler([ + new Response(200, [], Utils::streamFor($responseJson)), + ]); + $sa = new UserRefreshCredentials( + '', + $this->createTestJson() + ); + $sa->fetchAuthToken($httpHandler); + $this->assertEquals('scope/1 scope/2', $sa->getGrantedScope()); + } + + public function testShouldBeIdTokenWhenTargetAudienceIsSet() + { + $testJson = $this->createTestJson(); + $expectedToken = ['id_token' => 'idtoken12345']; + $timesCalled = 0; + $httpHandler = function ($request) use (&$timesCalled, $expectedToken) { + $timesCalled++; + parse_str($request->getBody(), $post); + + $this->assertArrayHasKey('target_audience', $post); + $this->assertEquals('a target audience', $post['target_audience']); + return new Response(200, [], Utils::streamFor(json_encode($expectedToken))); + }; + $sa = new UserRefreshCredentials(null, $testJson, 'a target audience'); + $this->assertEquals($expectedToken, $sa->fetchAuthToken($httpHandler)); + $this->assertEquals(1, $timesCalled); + } + + public function testSettingBothScopeAndTargetAudienceThrowsException() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Scope and targetAudience cannot both be supplied'); + + $testJson = $this->createTestJson(); + $sa = new UserRefreshCredentials('a-scope', $testJson, 'a-target-audience'); + } + + public function testGetQuotaProject() + { + $keyFile = __DIR__ . '/../fixtures/fixtures2' . '/private.json'; + $sa = new UserRefreshCredentials('a-scope', $keyFile); + $this->assertEquals('test_quota_project', $sa->getQuotaProject()); + } +} diff --git a/Auth/tests/CredentialsLoaderTest.php b/Auth/tests/CredentialsLoaderTest.php new file mode 100644 index 000000000000..b6af9cfbd1d3 --- /dev/null +++ b/Auth/tests/CredentialsLoaderTest.php @@ -0,0 +1,217 @@ +updateMetadata(['authentication' => 'foo']); + $this->assertArrayHasKey('authentication', $metadata); + $this->assertEquals('foo', $metadata['authentication']); + } + + /** @runInSeparateProcess */ + public function testGetDefaultClientCertSource() + { + $this->setHomeEnv(__DIR__ . '/fixtures/fixtures4/valid'); + + $callback = CredentialsLoader::getDefaultClientCertSource(); + $this->assertNotNull($callback); + + $output = $callback(); + $this->assertEquals('foo', $output); + } + + /** @runInSeparateProcess */ + public function testNonExistantDefaultClientCertSource() + { + $this->setHomeEnv(null); + + $callback = CredentialsLoader::getDefaultClientCertSource(); + $this->assertNull($callback); + } + + /** + * @runInSeparateProcess + */ + public function testDefaultClientCertSourceInvalidJsonThrowsException() + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Invalid client cert source JSON'); + + $this->setHomeEnv(__DIR__ . '/fixtures/fixtures4/invalidjson'); + + CredentialsLoader::getDefaultClientCertSource(); + } + + /** + * @runInSeparateProcess + */ + public function testDefaultClientCertSourceInvalidKeyThrowsException() + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('cert source requires "cert_provider_command"'); + + $this->setHomeEnv(__DIR__ . '/fixtures/fixtures4/invalidkey'); + + CredentialsLoader::getDefaultClientCertSource(); + } + + /** + * @runInSeparateProcess + */ + public function testDefaultClientCertSourceInvalidValueThrowsException() + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('cert source expects "cert_provider_command" to be an array'); + + $this->setHomeEnv(__DIR__ . '/fixtures/fixtures4/invalidvalue'); + + CredentialsLoader::getDefaultClientCertSource(); + } + + /** + * @runInSeparateProcess + */ + public function testActualDefaultClientCertSource() + { + $clientCertSource = CredentialsLoader::getDefaultClientCertSource(); + if (is_null($clientCertSource)) { + $this->markTestSkipped('No client cert source found'); + } + $creds = $clientCertSource(); + $this->assertTrue(is_string($creds)); + $this->assertStringContainsString('-----BEGIN CERTIFICATE-----', $creds); + $this->assertStringContainsString('-----BEGIN PRIVATE KEY-----', $creds); + } + + /** + * @runInSeparateProcess + */ + public function testDefaultClientCertSourceInvalidCmdThrowsException() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('"cert_provider_command" failed with a nonzero exit code'); + + $this->setHomeEnv(__DIR__ . '/fixtures/fixtures4/invalidcmd'); + + $callback = CredentialsLoader::getDefaultClientCertSource(); + + // Close stderr so output doesnt show in our test runner + fclose(STDERR); + + $callback(); + } + + /** + * @runInSeparateProcess + */ + public function testShouldLoadClientCertSourceInvalidValueIsFalse() + { + putenv(CredentialsLoader::MTLS_CERT_ENV_VAR . '=foo'); + + $this->assertFalse(CredentialsLoader::shouldLoadClientCertSource()); + } + + /** + * @runInSeparateProcess + */ + public function testShouldLoadClientCertSourceDefaultValueIsFalse() + { + putenv(CredentialsLoader::MTLS_CERT_ENV_VAR); + + $this->assertFalse(CredentialsLoader::shouldLoadClientCertSource()); + } + + /** + * @runInSeparateProcess + */ + public function testShouldLoadClientCertSourceIsTrue() + { + putenv(CredentialsLoader::MTLS_CERT_ENV_VAR . '=true'); + + $this->assertTrue(CredentialsLoader::shouldLoadClientCertSource()); + } + + /** + * @runInSeparateProcess + */ + public function testLoadJsonFromGetEnv(): void + { + putenv(CredentialsLoader::ENV_VAR . '=' . __DIR__ . '/fixtures/fixtures7/getenv.json'); + + $json = CredentialsLoader::fromEnv(); + + $this->assertArrayHasKey('type', $json); + $this->assertEquals('getenv', $json['type']); + } + + /** + * @runInSeparateProcess + */ + public function testLoadJsonFromEnv(): void + { + $_ENV[CredentialsLoader::ENV_VAR] = __DIR__ . '/fixtures/fixtures7/env.json'; + + $json = CredentialsLoader::fromEnv(); + + $this->assertArrayHasKey('type', $json); + $this->assertEquals('env', $json['type']); + } + + /** + * @runInSeparateProcess + */ + public function testLoadJsonFromGetEnvBackwardsCompatibility(): void + { + $_ENV[CredentialsLoader::ENV_VAR] = __DIR__ . '/fixtures/fixtures7/env.json'; + putenv(CredentialsLoader::ENV_VAR . '=' . __DIR__ . '/fixtures/fixtures7/getenv.json'); + + $json = CredentialsLoader::fromEnv(); + + $this->assertArrayHasKey('type', $json); + $this->assertEquals('getenv', $json['type']); + } +} + +class TestCredentialsLoader extends CredentialsLoader +{ + public function getCacheKey() + { + return 'test'; + } + + public function fetchAuthToken(?callable $httpHandler = null) + { + return 'test'; + } + + public function getLastReceivedToken() + { + return null; + } +} diff --git a/Auth/tests/ExecutableHandler/ExecutableHandlerTest.php b/Auth/tests/ExecutableHandler/ExecutableHandlerTest.php new file mode 100644 index 000000000000..7561a4b54622 --- /dev/null +++ b/Auth/tests/ExecutableHandler/ExecutableHandlerTest.php @@ -0,0 +1,57 @@ + 'foo', 'ENV_VAR_2' => 'bar']); + $this->assertEquals(0, $handler('bash -c "echo $ENV_VAR_1"')); + $this->assertEquals("foo\n", $handler->getOutput()); + + $this->assertEquals(0, $handler('bash -c "echo $ENV_VAR_2"')); + $this->assertEquals("bar\n", $handler->getOutput()); + } + + public function testTimeoutMs() + { + $handler = new ExecutableHandler([], 3000); + $this->assertEquals(0, $handler('bash -c \'sleep "0.1"\'')); + } + + public function testTimeoutMsExceeded() + { + $this->expectException(ExecutableResponseError::class); + $this->expectExceptionMessage('The executable failed to finish within the timeout specified.'); + + $handler = new ExecutableHandler([], 100); + $handler('sleep "0.2"'); + } + + public function testErrorOutputIsReturnedAsOutput() + { + $handler = new ExecutableHandler(); + $this->assertEquals(0, $handler('bash -c \'echo "Bad Response." >&2\'')); + $this->assertEquals("Bad Response.\n", $handler->getOutput()); + } +} diff --git a/Auth/tests/FetchAuthTokenCacheTest.php b/Auth/tests/FetchAuthTokenCacheTest.php new file mode 100644 index 000000000000..221c1e3c8313 --- /dev/null +++ b/Auth/tests/FetchAuthTokenCacheTest.php @@ -0,0 +1,806 @@ +mockFetcher = $this->prophesize(); + $this->mockFetcher->willImplement('Google\Auth\FetchAuthTokenInterface'); + $this->mockFetcher->willImplement('Google\Auth\UpdateMetadataInterface'); + $this->mockCacheItem = $this->prophesize('Psr\Cache\CacheItemInterface'); + $this->mockCache = $this->prophesize('Psr\Cache\CacheItemPoolInterface'); + $this->mockSigner = $this->prophesize('Google\Auth\SignBlobInterface'); + } + + public function testUsesCachedAccessToken() + { + $cacheKey = 'myKey'; + $token = '2/abcdef1234567890'; + $cachedValue = ['access_token' => $token]; + $this->mockCacheItem->isHit() + ->shouldBeCalledTimes(1) + ->willReturn(true); + $this->mockCacheItem->get() + ->shouldBeCalledTimes(1) + ->willReturn($cachedValue); + $this->mockCache->getItem($cacheKey) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockFetcher->fetchAuthToken() + ->shouldNotBeCalled(); + $this->mockFetcher->getCacheKey() + ->shouldBeCalled() + ->willReturn($cacheKey); + + // Run the test. + $cachedFetcher = new FetchAuthTokenCache( + $this->mockFetcher->reveal(), + null, + $this->mockCache->reveal() + ); + $accessToken = $cachedFetcher->fetchAuthToken(); + $this->assertEquals($accessToken, ['access_token' => $token]); + } + + public function testUsesCachedIdToken() + { + $cacheKey = 'myKey'; + $token = '2/abcdef1234567890'; + $cachedValue = ['id_token' => $token]; + $this->mockCacheItem->isHit() + ->shouldBeCalledTimes(1) + ->willReturn(true); + $this->mockCacheItem->get() + ->shouldBeCalledTimes(1) + ->willReturn($cachedValue); + $this->mockCache->getItem($cacheKey) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockFetcher->fetchAuthToken() + ->shouldNotBeCalled(); + $this->mockFetcher->getCacheKey() + ->shouldBeCalled() + ->willReturn($cacheKey); + + // Run the test. + $cachedFetcher = new FetchAuthTokenCache( + $this->mockFetcher->reveal(), + null, + $this->mockCache->reveal() + ); + $idToken = $cachedFetcher->fetchAuthToken(); + $this->assertEquals($idToken, ['id_token' => $token]); + } + + public function testUpdateMetadataWithCache() + { + $cacheKey = 'myKey'; + $token = '2/abcdef1234567890'; + $cachedValue = ['access_token' => $token]; + $this->mockCacheItem->isHit() + ->shouldBeCalledTimes(1) + ->willReturn(true); + $this->mockCacheItem->get() + ->shouldBeCalledTimes(1) + ->willReturn($cachedValue); + $this->mockCache->getItem($cacheKey) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockFetcher->fetchAuthToken() + ->shouldNotBeCalled(); + $this->mockFetcher->getCacheKey() + ->shouldBeCalled() + ->willReturn($cacheKey); + $this->mockFetcher->updateMetadata(Argument::type('array'), null, null) + ->shouldBeCalled() + ->will(function ($args, $fetcher) { + return $args[0]; + }); + + // Run the test. + $cachedFetcher = new FetchAuthTokenCache( + $this->mockFetcher->reveal(), + null, + $this->mockCache->reveal() + ); + $headers = $cachedFetcher->updateMetadata(['foo' => 'bar']); + $this->assertArrayHasKey('authorization', $headers); + $this->assertEquals(["Bearer $token"], $headers['authorization']); + $this->assertArrayHasKey('foo', $headers); + $this->assertEquals('bar', $headers['foo']); + } + + public function testUpdateMetadataWithoutCache() + { + $cacheKey = 'myKey'; + $token = '2/abcdef1234567890'; + $value = ['access_token' => $token]; + $this->mockCacheItem->isHit() + ->shouldBeCalledTimes(1) + ->willReturn(false); + $this->mockCache->getItem($cacheKey) + ->shouldBeCalledTimes(2) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockFetcher->getCacheKey() + ->shouldBeCalled() + ->willReturn($cacheKey); + $this->mockFetcher->getLastReceivedToken() + ->shouldBeCalled() + ->willReturn($value); + $this->mockCacheItem->set($value) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockCacheItem->expiresAfter(1500) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockCache->save($this->mockCacheItem) + ->shouldBeCalledTimes(1); + $this->mockFetcher->updateMetadata(Argument::type('array'), null, null) + ->shouldBeCalled() + ->will(function ($args, $fetcher) use ($token) { + $args[0]['authorization'] = ["Bearer $token"]; + return $args[0]; + }); + + // Run the test. + $cachedFetcher = new FetchAuthTokenCache( + $this->mockFetcher->reveal(), + null, + $this->mockCache->reveal() + ); + $headers = $cachedFetcher->updateMetadata(['foo' => 'bar']); + $this->assertArrayHasKey('authorization', $headers); + $this->assertEquals(["Bearer $token"], $headers['authorization']); + $this->assertArrayHasKey('foo', $headers); + $this->assertEquals('bar', $headers['foo']); + } + + public function testUpdateMetadataWithJwtAccess() + { + $privateKey = file_get_contents(__DIR__ . '/fixtures/fixtures1/private.pem'); + $testJson = [ + 'private_key' => $privateKey, + 'private_key_id' => 'key123', + 'client_email' => 'test@example.com', + 'client_id' => 'client123', + 'type' => 'service_account', + 'project_id' => 'example_project', + ]; + + $fetcher = new ServiceAccountCredentials(null, $testJson); + $cache = new MemoryCacheItemPool(); + + $cachedFetcher = new FetchAuthTokenCache( + $fetcher, + null, + $cache + ); + $metadata = $cachedFetcher->updateMetadata([], 'http://test-auth-uri'); + $this->assertArrayHasKey( + CredentialsLoader::AUTH_METADATA_KEY, + $metadata + ); + + $authorization = $metadata[CredentialsLoader::AUTH_METADATA_KEY]; + $this->assertTrue(is_array($authorization)); + + $bearerToken = current($authorization); + $this->assertTrue(is_string($bearerToken)); + $this->assertEquals(0, strpos($bearerToken, 'Bearer ')); + $token = str_replace('Bearer ', '', $bearerToken); + + $lastReceivedToken = $cachedFetcher->getLastReceivedToken(); + $this->assertArrayHasKey('access_token', $lastReceivedToken); + $this->assertEquals($token, $lastReceivedToken['access_token']); + + // Ensure token is cached + $metadata2 = $cachedFetcher->updateMetadata([], 'http://test-auth-uri'); + $this->assertEquals($metadata, $metadata2); + + // Ensure token for different URI is NOT cached + $metadata3 = $cachedFetcher->updateMetadata([], 'http://test-auth-uri-2'); + $this->assertNotEquals($metadata, $metadata3); + } + + public function testUpdateMetadataWithGceCredForIdToken() + { + $idToken = '123asdfghjkl'; + $httpHandler = $this->getHandler([ + new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']), + new Response(200, [], Utils::streamFor($idToken)), + ]); + $fetcher = new GCECredentials(null, null, 'https://example.test.com'); + $cache = new MemoryCacheItemPool(); + + $cachedFetcher = new FetchAuthTokenCache( + $fetcher, + null, + $cache + ); + $metadata = $cachedFetcher->updateMetadata( + [], + 'http://test-auth-uri', + $httpHandler + ); + $this->assertArrayHasKey( + CredentialsLoader::AUTH_METADATA_KEY, + $metadata + ); + + $authorization = $metadata[CredentialsLoader::AUTH_METADATA_KEY]; + $this->assertTrue(is_array($authorization)); + + $bearerToken = current($authorization); + $this->assertTrue(is_string($bearerToken)); + $this->assertEquals(0, strpos($bearerToken, 'Bearer ')); + $token = str_replace('Bearer ', '', $bearerToken); + + $lastReceivedToken = $cachedFetcher->getLastReceivedToken(); + $this->assertArrayHasKey('id_token', $lastReceivedToken); + $this->assertEquals($idToken, $lastReceivedToken['id_token']); + + // Ensure token is cached + $metadata2 = $cachedFetcher->updateMetadata([], 'http://test-auth-uri'); + $this->assertEquals($metadata, $metadata2); + + // Ensure token for different URI is NOT cached + $metadata3 = $cachedFetcher->updateMetadata( + [], + 'http://test-auth-uri-2', + $this->getHandler([new Response(200)]) + ); + $this->assertNotEquals($metadata, $metadata3); + } + + public function testUpdateMetadataWithInvalidFetcher() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Credentials fetcher does not implement Google\Auth\UpdateMetadataInterface'); + + $mockFetcher = $this->prophesize('Google\Auth\FetchAuthTokenInterface'); + + // Run the test. + $cachedFetcher = new FetchAuthTokenCache( + $mockFetcher->reveal(), + null, + $this->mockCache->reveal() + ); + $cachedFetcher->updateMetadata(['foo' => 'bar']); + } + + public function testShouldReturnValueWhenNotExpired() + { + $cacheKey = 'myKey'; + $token = '2/abcdef1234567890'; + $expiresAt = time() + 20; + $cachedValue = [ + 'access_token' => $token, + 'expires_at' => $expiresAt, + ]; + $this->mockCacheItem->isHit() + ->shouldBeCalledTimes(1) + ->willReturn(true); + $this->mockCacheItem->get() + ->shouldBeCalledTimes(1) + ->willReturn($cachedValue); + $this->mockCache->getItem($cacheKey) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockFetcher->fetchAuthToken() + ->shouldNotBeCalled(); + $this->mockFetcher->getCacheKey() + ->shouldBeCalled() + ->willReturn($cacheKey); + + // Run the test. + $cachedFetcher = new FetchAuthTokenCache( + $this->mockFetcher->reveal(), + null, + $this->mockCache->reveal() + ); + $accessToken = $cachedFetcher->fetchAuthToken(); + $this->assertEquals($accessToken, [ + 'access_token' => $token, + 'expires_at' => $expiresAt + ]); + } + + public function testShouldNotReturnValueWhenExpired() + { + $cacheKey = 'myKey'; + $token = '2/abcdef1234567890'; + $expiresAt = time() - 10; + $cachedValue = [ + 'access_token' => $token, + 'expires_at' => $expiresAt, + ]; + $newToken = ['access_token' => '3/abcdef1234567890']; + $this->mockCacheItem->isHit() + ->shouldBeCalledTimes(1) + ->willReturn(true); + $this->mockCacheItem->get() + ->shouldBeCalledTimes(1) + ->willReturn($cachedValue); + $this->mockCacheItem->set($newToken) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockCacheItem->expiresAfter(1500) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockCache->getItem($cacheKey) + ->shouldBeCalledTimes(2) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockFetcher->fetchAuthToken(null) + ->shouldBeCalledTimes(1) + ->willReturn($newToken); + $this->mockFetcher->getCacheKey() + ->shouldBeCalled() + ->willReturn($cacheKey); + $this->mockCache->save($this->mockCacheItem) + ->shouldBeCalledTimes(1); + + // Run the test. + $cachedFetcher = new FetchAuthTokenCache( + $this->mockFetcher->reveal(), + null, + $this->mockCache->reveal() + ); + $accessToken = $cachedFetcher->fetchAuthToken(); + $this->assertEquals($newToken, $accessToken); + } + + public function testShouldNotReturnValueWhenExpiredWithinEagerThreshold() + { + $cacheKey = 'myKey'; + $token = '2/abcdef1234567890'; + $expiresAt = time() + 5; + $cachedValue = [ + 'access_token' => $token, + 'expires_at' => $expiresAt, + ]; + $newToken = ['access_token' => '3/abcdef1234567890']; + $this->mockCacheItem->isHit() + ->shouldBeCalledTimes(1) + ->willReturn(true); + $this->mockCacheItem->get() + ->shouldBeCalledTimes(1) + ->willReturn($cachedValue); + $this->mockCacheItem->set($newToken) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockCacheItem->expiresAfter(1500) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockCache->getItem($cacheKey) + ->shouldBeCalledTimes(2) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockFetcher->fetchAuthToken(null) + ->shouldBeCalledTimes(1) + ->willReturn($newToken); + $this->mockFetcher->getCacheKey() + ->shouldBeCalled() + ->willReturn($cacheKey); + $this->mockCache->save($this->mockCacheItem) + ->shouldBeCalledTimes(1); + + // Run the test. + $cachedFetcher = new FetchAuthTokenCache( + $this->mockFetcher->reveal(), + null, + $this->mockCache->reveal() + ); + $accessToken = $cachedFetcher->fetchAuthToken(); + $this->assertEquals($newToken, $accessToken); + } + + public function testGetsCachedAuthTokenUsingCachePrefix() + { + $prefix = 'test_prefix_'; + $cacheKey = 'myKey'; + $token = '2/abcdef1234567890'; + $cachedValue = ['access_token' => $token]; + $this->mockCacheItem->isHit() + ->shouldBeCalledTimes(1) + ->willReturn(true); + $this->mockCacheItem->get() + ->shouldBeCalledTimes(1) + ->willReturn($cachedValue); + $this->mockCache->getItem($prefix . $cacheKey) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockFetcher->fetchAuthToken() + ->shouldNotBeCalled(); + $this->mockFetcher->getCacheKey() + ->shouldBeCalled() + ->willReturn($cacheKey); + + // Run the test + $cachedFetcher = new FetchAuthTokenCache( + $this->mockFetcher->reveal(), + ['prefix' => $prefix], + $this->mockCache->reveal() + ); + $accessToken = $cachedFetcher->fetchAuthToken(); + $this->assertEquals($accessToken, ['access_token' => $token]); + } + + public function testShouldSaveValueInCacheWithCacheOptions() + { + $prefix = 'test_prefix_'; + $lifetime = '70707'; + $cacheKey = 'myKey'; + $token = '1/abcdef1234567890'; + $cachedValue = ['access_token' => $token]; + $this->mockCacheItem->get(Argument::any()) + ->willReturn(null); + $this->mockCacheItem->isHit() + ->willReturn(false); + $this->mockCacheItem->set($cachedValue) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockCacheItem->expiresAfter($lifetime) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockCache->getItem($prefix . $cacheKey) + ->shouldBeCalledTimes(2) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockCache->save(Argument::type('Psr\Cache\CacheItemInterface')) + ->shouldBeCalled(); + $this->mockFetcher->getCacheKey() + ->willReturn($cacheKey); + $this->mockFetcher->fetchAuthToken(Argument::any()) + ->shouldBeCalledTimes(1) + ->willReturn($cachedValue); + + // Run the test + $cachedFetcher = new FetchAuthTokenCache( + $this->mockFetcher->reveal(), + ['prefix' => $prefix, 'lifetime' => $lifetime], + $this->mockCache->reveal() + ); + $accessToken = $cachedFetcher->fetchAuthToken(); + $this->assertEquals($accessToken, ['access_token' => $token]); + } + + public function testGetLastReceivedToken() + { + $token = 'foo'; + + $mockFetcher = $this->prophesize('Google\Auth\FetchAuthTokenInterface'); + $mockFetcher->getLastReceivedToken() + ->shouldBeCalled() + ->willReturn([ + 'access_token' => $token + ]); + + $fetcher = new FetchAuthTokenCache( + $mockFetcher->reveal(), + [], + $this->mockCache->reveal() + ); + + $this->assertEquals($token, $fetcher->getLastReceivedToken()['access_token']); + } + + public function testGetClientName() + { + $name = 'test@example.com'; + + $this->mockSigner->getClientName(null) + ->shouldBeCalled() + ->willReturn($name); + + $fetcher = new FetchAuthTokenCache( + $this->mockSigner->reveal(), + [], + $this->mockCache->reveal() + ); + + $this->assertEquals($name, $fetcher->getClientName()); + } + + public function testGetClientNameWithInvalidFetcher() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Credentials fetcher does not implement Google\Auth\SignBlobInterface'); + + $mockFetcher = $this->prophesize('Google\Auth\FetchAuthTokenInterface'); + + // Run the test. + $cachedFetcher = new FetchAuthTokenCache( + $mockFetcher->reveal(), + null, + $this->mockCache->reveal() + ); + $cachedFetcher->getClientName(); + } + + public function testSignBlob() + { + $stringToSign = 'foobar'; + $signature = 'helloworld'; + + $this->mockSigner->willImplement('Google\Auth\FetchAuthTokenInterface'); + $this->mockSigner->signBlob($stringToSign, true) + ->shouldBeCalled() + ->willReturn($signature); + + $fetcher = new FetchAuthTokenCache( + $this->mockSigner->reveal(), + [], + $this->mockCache->reveal() + ); + + $this->assertEquals($signature, $fetcher->signBlob($stringToSign, true)); + } + + public function testGCECredentialsSignBlob() + { + $stringToSign = 'foobar'; + $signature = 'helloworld'; + $cacheKey = 'myKey'; + $token = '2/abcdef1234567890'; + $cachedValue = ['access_token' => $token]; + + $mockGce = $this->prophesize('Google\Auth\Credentials\GCECredentials'); + $mockGce->signBlob($stringToSign, true, $token) + ->shouldBeCalled() + ->willReturn($signature); + + $this->mockCacheItem->isHit() + ->shouldBeCalledTimes(1) + ->willReturn(true); + $this->mockCacheItem->get() + ->shouldBeCalledTimes(1) + ->willReturn($cachedValue); + $this->mockCache->getItem($cacheKey) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + $mockGce->getCacheKey() + ->shouldBeCalled() + ->willReturn($cacheKey); + + $fetcher = new FetchAuthTokenCache( + $mockGce->reveal(), + [], + $this->mockCache->reveal() + ); + + $this->assertEquals($signature, $fetcher->signBlob($stringToSign, true)); + } + + public function testSignBlobInvalidFetcher() + { + $this->expectException(RuntimeException::class); + + $this->mockFetcher->signBlob('test') + ->shouldNotbeCalled(); + + $fetcher = new FetchAuthTokenCache( + $this->mockFetcher->reveal(), + [], + $this->mockCache + ); + + $fetcher->signBlob('test'); + } + + public function testGetProjectId() + { + $projectId = 'foobar'; + + $mockFetcher = $this->prophesize('Google\Auth\ProjectIdProviderInterface'); + $mockFetcher->willImplement('Google\Auth\FetchAuthTokenInterface'); + $mockFetcher->getProjectId(null) + ->shouldBeCalled() + ->willReturn($projectId); + + $fetcher = new FetchAuthTokenCache( + $mockFetcher->reveal(), + [], + $this->mockCache->reveal() + ); + + $this->assertEquals($projectId, $fetcher->getProjectId()); + } + + public function testGetProjectIdInvalidFetcher() + { + $this->expectException(RuntimeException::class); + + $mockFetcher = $this->prophesize('Google\Auth\FetchAuthTokenInterface'); + $mockFetcher->getProjectId() + ->shouldNotbeCalled(); + + $fetcher = new FetchAuthTokenCache( + $mockFetcher->reveal(), + [], + $this->mockCache + ); + + $fetcher->getProjectId(); + } + + public function testGetUniverseDomain() + { + $universeDomain = 'foobar'; + + $mockFetcher = $this->prophesize('Google\Auth\GetUniverseDomainInterface'); + $mockFetcher->willImplement('Google\Auth\FetchAuthTokenInterface'); + $mockFetcher->getUniverseDomain() + ->shouldBeCalled() + ->willReturn($universeDomain); + + $fetcher = new FetchAuthTokenCache( + $mockFetcher->reveal(), + [], + $this->mockCache->reveal() + ); + + $this->assertEquals($universeDomain, $fetcher->getUniverseDomain()); + } + + public function testGetUniverseDomainInvalidFetcher() + { + $mockFetcher = $this->prophesize('Google\Auth\FetchAuthTokenInterface'); + + $fetcher = new FetchAuthTokenCache( + $mockFetcher->reveal(), + [], + $this->mockCache->reveal() + ); + + $this->assertEquals( + GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN, + $fetcher->getUniverseDomain() + ); + } + + public function testGetFetcher() + { + $mockFetcher = $this->prophesize('Google\Auth\FetchAuthTokenInterface') + ->reveal(); + $fetcher = new FetchAuthTokenCache( + $mockFetcher, + [], + $this->mockCache->reveal() + ); + + $this->assertSame($mockFetcher, $fetcher->getFetcher()); + } + + public function testCacheUniverseDomain() + { + $mockFetcher = $this->prophesize(FetchAuthTokenInterface::class); + $mockFetcher->willImplement(GetUniverseDomainInterface::class); + $mockFetcher->getUniverseDomain() + ->shouldBeCalledTimes(2) + ->willReturn('example-universe.domain'); + $mockFetcher->getCacheKey() + ->shouldNotBeCalled(); + + $fetcher = new FetchAuthTokenCache( + $mockFetcher->reveal(), + ['cacheUniverseDomain' => false], + new MemoryCacheItemPool() + ); + + // Call it twice + $this->assertEquals('example-universe.domain', $fetcher->getUniverseDomain()); + $this->assertEquals('example-universe.domain', $fetcher->getUniverseDomain()); + + // Now set the cache option and ensure it's only called once + $mockFetcher = $this->prophesize(FetchAuthTokenInterface::class); + $mockFetcher->willImplement(GetUniverseDomainInterface::class); + $mockFetcher->getUniverseDomain() + ->shouldBeCalledOnce() + ->willReturn('example-universe.domain'); + $mockFetcher->getCacheKey() + ->shouldBeCalledTimes(2) + ->willReturn('my-cache-key'); + + $fetcher = new FetchAuthTokenCache( + $mockFetcher->reveal(), + ['cacheUniverseDomain' => true], + new MemoryCacheItemPool() + ); + $this->assertEquals('example-universe.domain', $fetcher->getUniverseDomain()); + $this->assertEquals('example-universe.domain', $fetcher->getUniverseDomain()); + } + + public function testCacheUniverseDomainByDefaultForGCECredentials() + { + $mockFetcher = $this->prophesize(GCECredentials::class); + $mockFetcher->getUniverseDomain() + ->shouldBeCalledOnce() + ->willReturn('example-universe.domain'); + $mockFetcher->getCacheKey() + ->shouldBeCalledTimes(2) + ->willReturn('my-cache-key'); + + $fetcher = new FetchAuthTokenCache( + $mockFetcher->reveal(), + [], // don't set cacheUniverseDomain, it will be true by default + new MemoryCacheItemPool() + ); + + $this->assertEquals('example-universe.domain', $fetcher->getUniverseDomain()); + $this->assertEquals('example-universe.domain', $fetcher->getUniverseDomain()); + } + + public function testUniverseDomainWithFileCache() + { + require_once __DIR__ . '/mocks/TestFileCacheItemPool.php'; + self::$cacheKey = 'universe-domain-check-' . time() . rand(); + + $cache = new TestFileCacheItemPool(sys_get_temp_dir() . '/google-auth-test'); + + $mockFetcher = $this->prophesize(FetchAuthTokenInterface::class); + $mockFetcher->willImplement(GetUniverseDomainInterface::class); + $mockFetcher->getUniverseDomain() + ->shouldBeCalledOnce() + ->willReturn('example-universe.domain'); + $mockFetcher->getCacheKey() + ->shouldBeCalledOnce() + ->willReturn(self::$cacheKey); + + $fetcher = new FetchAuthTokenCache( + $mockFetcher->reveal(), + ['cacheUniverseDomain' => true], + $cache + ); + $this->assertEquals('example-universe.domain', $fetcher->getUniverseDomain()); + } + + /** + * @depends testUniverseDomainWithFileCache + */ + public function testUniverseDomainWithFileCacheProcess2() + { + $cmd = sprintf('php %s/mocks/test_file_cache_separate_process.php %s', __DIR__, self::$cacheKey); + exec($cmd, $output, $retVar); + + $this->assertEquals(0, $retVar); + $this->assertEquals('example-universe.domain', implode('', $output)); + } +} diff --git a/Auth/tests/FetchAuthTokenTest.php b/Auth/tests/FetchAuthTokenTest.php new file mode 100644 index 000000000000..ed78383516d8 --- /dev/null +++ b/Auth/tests/FetchAuthTokenTest.php @@ -0,0 +1,245 @@ +prophesize($fetcherClass); + + $httpHandlerCalled = false; + $httpHandler = function () use (&$httpHandlerCalled) { + $httpHandlerCalled = true; + return ['access_token' => 'xyz']; + }; + + if (in_array( + 'Google\Auth\GetQuotaProjectInterface', + class_implements($fetcherClass) + )) { + $mockFetcher->getQuotaProject()->shouldBeCalledTimes(1); + } + + if (is_a($fetcherClass, UpdateMetadataInterface::class, true)) { + $mockFetcher->updateMetadata(Argument::cetera()) + ->shouldBeCalledTimes(1)->will(function () use (&$httpHandlerCalled) { + $httpHandlerCalled = true; + return ['authorization' => ['Bearer xyz']]; + }); + } else { + $mockFetcher->fetchAuthToken(Argument::any()) + ->shouldBeCalledTimes(1) + ->will($httpHandler); + } + $mockFetcher->getCacheKey()->willReturn(''); + $mockFetcher->getLastReceivedToken()->willReturn(['access_token' => 'xyz']); + + $tokenCallbackCalled = false; + $tokenCallback = function ($cacheKey, $accessToken) use (&$tokenCallbackCalled) { + $tokenCallbackCalled = true; + $this->assertEquals('xyz', $accessToken); + }; + + $clientOptions = [ + 'base_uri' => 'https://www.googleapis.com/books/v1/', + 'http_errors' => false, + ]; + + $client = CredentialsLoader::makeHttpClient( + $mockFetcher->reveal(), + $clientOptions, + $httpHandler, + $tokenCallback + ); + + $response = $client->get( + 'volumes?q=Henry+David+Thoreau&country=US' + ); + + $this->assertEquals(401, $response->getStatusCode()); + $this->assertTrue($httpHandlerCalled); + $this->assertTrue($tokenCallbackCalled); + } + + public function provideMakeHttpClient() + { + return [ + ['Google\Auth\Credentials\AppIdentityCredentials'], + ['Google\Auth\Credentials\ExternalAccountCredentials'], + ['Google\Auth\Credentials\GCECredentials'], + ['Google\Auth\Credentials\ImpersonatedServiceAccountCredentials'], + ['Google\Auth\Credentials\ServiceAccountCredentials'], + ['Google\Auth\Credentials\ServiceAccountJwtAccessCredentials'], + ['Google\Auth\Credentials\UserRefreshCredentials'], + ['Google\Auth\OAuth2'], + ]; + } + + public function testAppIdentityCredentialsGetLastReceivedToken() + { + $class = new \ReflectionClass( + 'Google\Auth\Credentials\AppIdentityCredentials' + ); + $property = $class->getProperty('lastReceivedToken'); + + $credentials = new AppIdentityCredentials(); + $property->setValue($credentials, [ + 'access_token' => 'xyz', + 'expiration_time' => strtotime('2001'), + ]); + + $this->assertGetLastReceivedToken($credentials); + } + + public function testGCECredentialsGetLastReceivedToken() + { + $class = new \ReflectionClass( + 'Google\Auth\Credentials\GCECredentials' + ); + $property = $class->getProperty('lastReceivedToken'); + + $credentials = new GCECredentials(); + $property->setValue($credentials, [ + 'access_token' => 'xyz', + 'expires_at' => strtotime('2001'), + ]); + + $this->assertGetLastReceivedToken($credentials); + } + + public function testServiceAccountCredentialsGetLastReceivedToken() + { + $jsonPath = sprintf( + '%s/fixtures/fixtures1/.config/%s', + __DIR__, + CredentialsLoader::WELL_KNOWN_PATH + ); + + $class = new \ReflectionClass( + 'Google\Auth\Credentials\ServiceAccountCredentials' + ); + $property = $class->getProperty('auth'); + + $oauth2Mock = $this->getOAuth2Mock(); + $oauth2Mock->getScope() + ->willReturn($this->scopes); + $oauth2Mock->getAdditionalClaims() + ->willReturn([]); + $oauth2Mock->getSub() + ->willReturn(null); + + $credentials = new ServiceAccountCredentials($this->scopes, $jsonPath); + $property->setValue($credentials, $oauth2Mock->reveal()); + + $this->assertGetLastReceivedToken($credentials); + } + + public function testServiceAccountJwtAccessCredentialsGetLastReceivedToken() + { + $jsonPath = sprintf( + '%s/fixtures/fixtures1/.config/%s', + __DIR__, + CredentialsLoader::WELL_KNOWN_PATH + ); + + $class = new \ReflectionClass( + 'Google\Auth\Credentials\ServiceAccountJwtAccessCredentials' + ); + $property = $class->getProperty('auth'); + + $credentials = new ServiceAccountJwtAccessCredentials($jsonPath); + $property->setValue($credentials, $this->getOAuth2Mock()->reveal()); + + $this->assertGetLastReceivedToken($credentials); + } + + public function testUserRefreshCredentialsGetLastReceivedToken() + { + $jsonPath = sprintf( + '%s/fixtures/fixtures2/.config/%s', + __DIR__, + CredentialsLoader::WELL_KNOWN_PATH + ); + + $class = new \ReflectionClass( + 'Google\Auth\Credentials\UserRefreshCredentials' + ); + $property = $class->getProperty('auth'); + + $credentials = new UserRefreshCredentials($this->scopes, $jsonPath); + $property->setValue($credentials, $this->getOAuth2Mock()->reveal()); + + $this->assertGetLastReceivedToken($credentials); + } + + private function getOAuth2() + { + $oauth = new OAuth2([ + 'access_token' => 'xyz', + 'expires_at' => strtotime('2001'), + ]); + + $this->assertGetLastReceivedToken($oauth); + } + + private function getOAuth2Mock() + { + $mock = $this->prophesize('Google\Auth\OAuth2'); + + $mock->getLastReceivedToken() + ->shouldBeCalledTimes(1) + ->willReturn([ + 'access_token' => 'xyz', + 'expires_at' => strtotime('2001'), + ]); + + return $mock; + } + + private function assertGetLastReceivedToken(FetchAuthTokenInterface $fetcher) + { + $accessToken = $fetcher->getLastReceivedToken(); + + $this->assertNotNull($accessToken); + $this->assertArrayHasKey('access_token', $accessToken); + $this->assertArrayHasKey('expires_at', $accessToken); + + $this->assertEquals('xyz', $accessToken['access_token']); + $this->assertEquals(strtotime('2001'), $accessToken['expires_at']); + } +} diff --git a/Auth/tests/GCECacheTest.php b/Auth/tests/GCECacheTest.php new file mode 100644 index 000000000000..7ed0491e3717 --- /dev/null +++ b/Auth/tests/GCECacheTest.php @@ -0,0 +1,167 @@ +mockCacheItem = $this->prophesize('Psr\Cache\CacheItemInterface'); + $this->mockCache = $this->prophesize('Psr\Cache\CacheItemPoolInterface'); + } + + public function testCachedOnGceTrueValue() + { + $cachedValue = true; + $this->mockCacheItem->isHit() + ->shouldBeCalledTimes(1) + ->willReturn(true); + $this->mockCacheItem->get() + ->shouldBeCalledTimes(1) + ->willReturn($cachedValue); + $this->mockCache->getItem(GCECache::GCE_CACHE_KEY) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + + // Run the test. + $gceCache = new GCECache( + null, + $this->mockCache->reveal() + ); + $this->assertTrue($gceCache->onGce()); + } + + public function testCachedOnGceFalseValue() + { + $cachedValue = false; + $this->mockCacheItem->isHit() + ->shouldBeCalledTimes(1) + ->willReturn(true); + $this->mockCacheItem->get() + ->shouldBeCalledTimes(1) + ->willReturn($cachedValue); + $this->mockCache->getItem(GCECache::GCE_CACHE_KEY) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + + // Run the test. + $gceCache = new GCECache( + null, + $this->mockCache->reveal() + ); + $this->assertFalse($gceCache->onGce()); + } + + public function testUncached() + { + $gceIsCalled = false; + $dummyHandler = function ($request) use (&$gceIsCalled) { + $gceIsCalled = true; + return new Psr7\Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']); + }; + + $this->mockCacheItem->isHit() + ->shouldBeCalledTimes(1) + ->willReturn(false); + $this->mockCacheItem->set(true) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockCacheItem->expiresAfter(1500) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockCache->getItem(GCECache::GCE_CACHE_KEY) + ->shouldBeCalledTimes(2) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockCache->save($this->mockCacheItem->reveal()) + ->shouldBeCalledTimes(1); + + // Run the test. + $gceCache = new GCECache( + null, + $this->mockCache->reveal() + ); + + $this->assertTrue($gceCache->onGce($dummyHandler)); + $this->assertTrue($gceIsCalled); + } + + public function testShouldFetchFromCacheWithCacheOptions() + { + $prefix = 'test_prefix_'; + $lifetime = '70707'; + $cachedValue = true; + + $this->mockCacheItem->isHit() + ->willReturn(true); + $this->mockCacheItem->get() + ->willReturn($cachedValue); + $this->mockCache->getItem($prefix . GCECache::GCE_CACHE_KEY) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + + // Run the test + $gceCache = new GCECache( + ['prefix' => $prefix, 'lifetime' => $lifetime], + $this->mockCache->reveal() + ); + $this->assertTrue($gceCache->onGce()); + } + + public function testShouldSaveValueInCacheWithCacheOptions() + { + $prefix = 'test_prefix_'; + $lifetime = '70707'; + $gceIsCalled = false; + $dummyHandler = function ($request) use (&$gceIsCalled) { + $gceIsCalled = true; + return new Psr7\Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']); + }; + $this->mockCacheItem->isHit() + ->willReturn(false); + $this->mockCacheItem->set(true) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockCacheItem->expiresAfter($lifetime) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockCache->getItem($prefix . GCECache::GCE_CACHE_KEY) + ->shouldBeCalledTimes(2) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockCache->save($this->mockCacheItem->reveal()) + ->shouldBeCalled(); + + // Run the test + $gceCache = new GCECache( + ['prefix' => $prefix, 'lifetime' => $lifetime], + $this->mockCache->reveal() + ); + $onGce = $gceCache->onGce($dummyHandler); + $this->assertTrue($onGce); + $this->assertTrue($gceIsCalled); + } +} diff --git a/Auth/tests/HelperTrait.php b/Auth/tests/HelperTrait.php new file mode 100644 index 000000000000..5725540b1756 --- /dev/null +++ b/Auth/tests/HelperTrait.php @@ -0,0 +1,41 @@ + $handler]); + + return new Guzzle7HttpHandler($client); + } + + private function setHomeEnv(?string $value): void + { + $assigment = sprintf( + "%s%s%s", + PHP_OS_FAMILY === "Windows" ? "APPDATA" : "HOME", + $value === null ? "" : "=", + (string) $value + ); + + putenv($assigment); + } + + private function skipResidencyCheck(bool $skip = true): void + { + $prop = new \ReflectionProperty( + \Google\Auth\Credentials\GCECredentials::class, + 'checkResidency' + ); + $prop->setValue(null, !$skip); + } +} diff --git a/Auth/tests/HttpHandler/Guzzle7HttpHandlerTest.php b/Auth/tests/HttpHandler/Guzzle7HttpHandlerTest.php new file mode 100644 index 000000000000..1a59034c0141 --- /dev/null +++ b/Auth/tests/HttpHandler/Guzzle7HttpHandlerTest.php @@ -0,0 +1,113 @@ +client = $this->prophesize('GuzzleHttp\ClientInterface'); + $this->handler = new Guzzle7HttpHandler($this->client->reveal()); + } + + public function testSuccessfullySendsRequest() + { + $request = new Request('GET', 'https://domain.tld'); + $options = ['key' => 'value']; + $response = new Response(200); + + $this->client->send($request, $options)->willReturn($response); + + $handler = $this->handler; + + $this->assertSame($response, $handler($request, $options)); + } + + public function testSuccessfullySendsRequestAsync() + { + $request = new Request('GET', 'https://domain.tld'); + $options = ['key' => 'value']; + $response = new Response(200); + $promise = new FulfilledPromise($response); + + $this->client->sendAsync($request, $options)->willReturn($promise); + + $handler = $this->handler; + + $this->assertSame($response, $handler->async($request, $options)->wait()); + } + + public function testLoggerGetsCalledIfLoggerIsPassed() + { + $requestPromise = new Promise(function () use (&$requestPromise) { + $response = new Response(200); + $requestPromise->resolve($response); + }); + + $mockLogger = $this->prophesize(StdOutLogger::class); + $mockLogger->debug(Argument::cetera()) + ->shouldBeCalledTimes(2); + + $this->client->sendAsync(Argument::cetera()) + ->willReturn($requestPromise); + + $request = new Request('GET', 'https://domain.tld'); + $options = ['key' => 'value']; + + $handler = new Guzzle7HttpHandler($this->client->reveal(), $mockLogger->reveal()); + $handler->async($request, $options)->wait(); + } + + public function testLoggerDoesNotGetsCalledIfLoggerIsNotPassed() + { + $requestPromise = new Promise(function () use (&$requestPromise) { + $response = new Response(200); + $requestPromise->resolve($response); + }); + + $this->client->sendAsync(Argument::cetera()) + ->willReturn($requestPromise) + ->shouldBeCalledTimes(1); + + $request = new Request('GET', 'https://domain.tld'); + $options = ['key' => 'value']; + + $handler = new Guzzle7HttpHandler($this->client->reveal()); + $handler->async($request, $options)->wait(); + + $this->expectOutputString(''); + } +} diff --git a/Auth/tests/HttpHandler/HttpHandlerFactoryTest.php b/Auth/tests/HttpHandler/HttpHandlerFactoryTest.php new file mode 100644 index 000000000000..d91d83da76b1 --- /dev/null +++ b/Auth/tests/HttpHandler/HttpHandlerFactoryTest.php @@ -0,0 +1,67 @@ +assertInstanceOf('Google\Auth\HttpHandler\Guzzle7HttpHandler', $handler); + } + + public function testBuildsGuzzle7HandlerWithExtendedTruncation() + { + // Guzzle defaults to 120 characters. We expect to see our message truncated at 240 + $defaultTruncatedLength = 240; + $longMessage = str_repeat('x', $defaultTruncatedLength + 1); + $expectedMessage = str_repeat('x', $defaultTruncatedLength) . ' (truncated...)'; + $this->expectException(RequestException::class); + $this->expectExceptionMessage($expectedMessage); + + // Create a mock error response with a long message + $newStack = HandlerStack::create(new MockHandler([ + new Response(500, [], $longMessage), + ])); + + // Get access to the default middleware stack so we can add it to our mock handler + $handler = HttpHandlerFactory::build(); + $clientProp = (new ReflectionClass($handler))->getParentClass()->getProperty('client'); + + $handlerStack = $clientProp->getValue($handler)->getConfig('handler'); + $stackProp = (new ReflectionClass($handlerStack))->getProperty('stack'); + + foreach ($stackProp->getValue($handlerStack) as $idx => $middleware) { + $newStack->push($middleware[0], $middleware[1]); + } + + $client = new Client(['handler' => $newStack]); + $client->request('GET', '/'); + } +} diff --git a/Auth/tests/IamTest.php b/Auth/tests/IamTest.php new file mode 100644 index 000000000000..eabaef32eac1 --- /dev/null +++ b/Auth/tests/IamTest.php @@ -0,0 +1,101 @@ +assertEquals($expectedUri, (string) $request->getUri()); + $this->assertEquals('Bearer ' . $expectedAccessToken, $request->getHeaderLine('Authorization')); + $this->assertEquals([ + 'delegates' => $expectedDelegates, + 'payload' => base64_encode($expectedString) + ], json_decode((string) $request->getBody(), true)); + + return new Psr7\Response(200, [], Utils::streamFor(json_encode([ + 'signedBlob' => $expectedResponse + ]))); + }; + + $iam = new Iam($httpHandler); + $res = $iam->signBlob( + $expectedEmail, + $expectedAccessToken, + $expectedString, + $delegates + ); + + $this->assertEquals($expectedResponse, $res); + } + + public function delegates() + { + return [ + [], + [['foo@bar.com']], + [ + [ + 'foo@bar.com', + 'bar@bar.com' + ] + ], + ]; + } +} diff --git a/Auth/tests/Logging/LoggingTraitTest.php b/Auth/tests/Logging/LoggingTraitTest.php new file mode 100644 index 000000000000..94c2b429eab0 --- /dev/null +++ b/Auth/tests/Logging/LoggingTraitTest.php @@ -0,0 +1,123 @@ +loggerContainer = new class() { + use LoggingTrait { + logRequest as public; + logResponse as public; + } + + private LoggerInterface $logger; + + public function __construct() + { + $this->logger = new StdOutLogger(); + } + }; + } + + public function testLogRequest() + { + $event = $this->getNewLogEvent(); + $this->loggerContainer->logRequest($event); + + $buffer = $this->getActualOutput(); + $jsonParsed = json_decode($buffer, true); + + $this->assertEquals($event->timestamp, $jsonParsed['timestamp']); + $this->assertEquals($event->processId, $jsonParsed['processId']); + $this->assertEquals($event->method, $jsonParsed['jsonPayload']['request.method']); + $this->assertEquals($event->url, $jsonParsed['jsonPayload']['request.url']); + $this->assertEquals($event->headers, $jsonParsed['jsonPayload']['request.headers']); + $this->assertArrayHasKey('request.jwt', $jsonParsed['jsonPayload']); + } + + public function testRequestWithoutJwtShouldNotPrintAJwt() + { + $event = $this->getNewLogEvent(); + $event->headers = ['no jwt' => true]; + $this->loggerContainer->logRequest($event); + + $buffer = $this->getActualOutput(); + $jsonParsed = json_decode($buffer, true); + + $this->assertArrayNotHasKey('request.jwt', $jsonParsed['jsonPayload']); + } + + public function testLogResponse() + { + $event = $this->getNewLogEvent(); + $event->headers = ['Thisis' => 'a header']; + $this->loggerContainer->logResponse($event); + + $buffer = $this->getActualOutput(); + + $parsedDebugEvent = json_decode($buffer, true); + $this->assertEquals($event->processId, $parsedDebugEvent['processId']); + $this->assertEquals($event->requestId, $parsedDebugEvent['requestId']); + $this->assertEquals($event->headers, $parsedDebugEvent['jsonPayload']['response.headers']); + } + + public function testRpcNameShouldBeIncluded() + { + $event = $this->getNewLogEvent(); + $event->headers = ['Thisis' => 'a header']; + $this->loggerContainer->logRequest($event); + + $buffer = $this->getActualOutput(); + + $parsedDebugEvent = json_decode($buffer, true); + $this->assertEquals($event->rpcName, $parsedDebugEvent['rpcName']); + } + + private function getNewLogEvent(): RpcLogEvent + { + $event = new RpcLogEvent(); + $event->processId = 123; + $event->method = 'get'; + $event->url = 'test.com'; + $event->headers = [ + 'header1' => 'test', + 'Authorization' => 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIx' + . 'MjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.' + . 'cThIIoDvwdueQB468K5xDc5633seEFoqwxjF_xSJyQQ' + ]; + $event->payload = json_encode(['param' => 'test']); + $event->status = 200; + $event->retryAttempt = 0; + $event->rpcName = 'Rpc NameTest'; + $event->serviceName = 'Service Name'; + $event->requestId = 321; + $event->latency = 555; + + return $event; + } +} diff --git a/Auth/tests/Logging/RpcLogEventTest.php b/Auth/tests/Logging/RpcLogEventTest.php new file mode 100644 index 000000000000..63a948ef4f27 --- /dev/null +++ b/Auth/tests/Logging/RpcLogEventTest.php @@ -0,0 +1,47 @@ +assertNotNull($item->timestamp); + } + + public function testConstructorWithoutParameterHasNoLatency() + { + $item = new RpcLogEvent(); + $this->assertNull($item->latency); + } + + public function testConstructorWithParameterHasLatencySet() + { + // We sustract 1000 ms to simulate a microtime 1000ms in the past + $previousMicrotimeInMillis = (microtime(true) * 1000) - 1000; + $item = new RpcLogEvent($previousMicrotimeInMillis); + $this->assertNotNull($item->latency); + + // Adding a delta to the test due timing on how this executes + $this->assertEqualsWithDelta(1000, $item->latency, 5); + } +} diff --git a/Auth/tests/Logging/StdOutLoggerTest.php b/Auth/tests/Logging/StdOutLoggerTest.php new file mode 100644 index 000000000000..67b54bd9210b --- /dev/null +++ b/Auth/tests/Logging/StdOutLoggerTest.php @@ -0,0 +1,59 @@ +expectException(InvalidArgumentException::class); + new StdOutLogger('invalid level'); + } + + public function testLoggingOnSameLevelWritesToStdOut() + { + $expectedString = 'test'; + $this->expectOutputString($expectedString . "\n"); + + $logger = new StdOutLogger(LogLevel::DEBUG); + $logger->debug($expectedString); + } + + public function testLoggingOnHigherLeverWritesToStdOut() + { + $expectedString = 'test'; + $this->expectOutputString($expectedString . "\n"); + + $logger = new StdOutLogger(LogLevel::WARNING); + $logger->error($expectedString); + } + + public function testLoggingOnLowerLeverDoesNotWriteToStdOut() + { + $this->expectOutputString(''); + + $logger = new StdOutLogger(LogLevel::WARNING); + $expectedString = 'test'; + $logger->debug($expectedString); + } +} diff --git a/Auth/tests/MetricsTraitTest.php b/Auth/tests/MetricsTraitTest.php new file mode 100644 index 000000000000..7c54cf6ec1ce --- /dev/null +++ b/Auth/tests/MetricsTraitTest.php @@ -0,0 +1,63 @@ +impl = new class() { + use MetricsTrait{ + getVersion as public; + getMetricsHeader as public; + } + }; + } + + public function testGetVersion() + { + $actualVersion = $this->impl::getVersion(); + $this->assertStringMatchesFormat('%d.%d.%d', $actualVersion); + } + + /** + * @dataProvider metricsHeaderCases + */ + public function testGetMetricsHeader($credType, $authRequestType, $expected) + { + $headerValue = $this->impl::getMetricsHeader($credType, $authRequestType); + $this->assertStringMatchesFormat('gl-php/%s auth/%s ' . $expected, $headerValue); + } + + public function metricsHeaderCases() + { + return [ + ['foo', '', 'cred-type/foo'], + ['', 'bar', 'auth-request-type/bar'], + ['foo', 'bar', 'auth-request-type/bar cred-type/foo'] + ]; + } +} diff --git a/Auth/tests/Middleware/AuthTokenMiddlewareTest.php b/Auth/tests/Middleware/AuthTokenMiddlewareTest.php new file mode 100644 index 000000000000..53db130ab1d7 --- /dev/null +++ b/Auth/tests/Middleware/AuthTokenMiddlewareTest.php @@ -0,0 +1,402 @@ +mockFetcher = $this->prophesize('Google\Auth\FetchAuthTokenInterface'); + $this->mockCacheItem = $this->prophesize('Psr\Cache\CacheItemInterface'); + $this->mockCache = $this->prophesize('Psr\Cache\CacheItemPoolInterface'); + $this->mockRequest = $this->prophesize('GuzzleHttp\Psr7\Request'); + } + + public function testOnlyTouchesWhenAuthConfigScoped() + { + $this->mockFetcher->fetchAuthToken(Argument::any()) + ->willReturn([]); + $this->mockRequest->withHeader()->shouldNotBeCalled(); + + $middleware = new AuthTokenMiddleware($this->mockFetcher->reveal()); + $mock = new MockHandler([new Response(200)]); + $callable = $middleware($mock); + $callable($this->mockRequest->reveal(), ['auth' => 'not_google_auth']); + } + + public function testAddsTheTokenAsAnAuthorizationHeader() + { + $authResult = ['access_token' => '1/abcdef1234567890']; + $this->mockFetcher->fetchAuthToken(Argument::any()) + ->shouldBeCalledTimes(1) + ->willReturn($authResult); + $this->mockRequest->withHeader('authorization', 'Bearer ' . $authResult['access_token']) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockRequest->reveal()); + + $this->runTestCase($this->mockFetcher->reveal()); + } + + public function testDoesNotAddAnAuthorizationHeaderOnNoAccessToken() + { + $authResult = ['not_access_token' => '1/abcdef1234567890']; + $this->mockFetcher->fetchAuthToken(Argument::any()) + ->shouldBeCalledTimes(1) + ->willReturn($authResult); + $this->mockRequest->withHeader('authorization', 'Bearer ') + ->willReturn($this->mockRequest->reveal()); + + $this->runTestCase($this->mockFetcher->reveal()); + } + + public function testUsesIdTokenWhenAccessTokenDoesNotExist() + { + $token = 'idtoken12345'; + $authResult = ['id_token' => $token]; + $this->mockFetcher->fetchAuthToken(Argument::any()) + ->shouldBeCalledTimes(1) + ->willReturn($authResult); + $this->mockRequest->withHeader('authorization', 'Bearer ' . $token) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockRequest->reveal()); + + $this->runTestCase($this->mockFetcher->reveal()); + } + + public function testUsesCachedAccessToken() + { + $cacheKey = 'myKey'; + $accessToken = '2/abcdef1234567890'; + $cachedValue = ['access_token' => $accessToken]; + $this->mockCacheItem->isHit() + ->shouldBeCalledTimes(1) + ->willReturn(true); + $this->mockCacheItem->get() + ->shouldBeCalledTimes(1) + ->willReturn($cachedValue); + $this->mockCache->getItem($cacheKey) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockFetcher->fetchAuthToken() + ->shouldNotBeCalled(); + $this->mockFetcher->getCacheKey() + ->shouldBeCalled() + ->willReturn($cacheKey); + $this->mockRequest->withHeader('authorization', 'Bearer ' . $accessToken) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockRequest->reveal()); + + // Run the test. + $cachedFetcher = new FetchAuthTokenCache( + $this->mockFetcher->reveal(), + null, + $this->mockCache->reveal() + ); + $this->runTestCase($cachedFetcher); + } + + public function testUsesCachedIdToken() + { + $cacheKey = 'myKey'; + $idToken = '2/abcdef1234567890'; + $cachedValue = ['id_token' => $idToken]; + $this->mockCacheItem->isHit() + ->shouldBeCalledTimes(1) + ->willReturn(true); + $this->mockCacheItem->get() + ->shouldBeCalledTimes(1) + ->willReturn($cachedValue); + $this->mockCache->getItem($cacheKey) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockFetcher->fetchAuthToken() + ->shouldNotBeCalled(); + $this->mockFetcher->getCacheKey() + ->shouldBeCalled() + ->willReturn($cacheKey); + $this->mockRequest->withHeader('authorization', 'Bearer ' . $idToken) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockRequest->reveal()); + + // Run the test. + $cachedFetcher = new FetchAuthTokenCache( + $this->mockFetcher->reveal(), + null, + $this->mockCache->reveal() + ); + $this->runTestCase($cachedFetcher); + } + + public function testGetsCachedAuthTokenUsingCacheOptions() + { + $prefix = 'test_prefix_'; + $cacheKey = 'myKey'; + $token = '2/abcdef1234567890'; + $cachedValue = ['access_token' => $token]; + $this->mockCacheItem->isHit() + ->shouldBeCalledTimes(1) + ->willReturn(true); + $this->mockCacheItem->get() + ->shouldBeCalledTimes(1) + ->willReturn($cachedValue); + $this->mockCache->getItem($prefix . $cacheKey) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockFetcher->fetchAuthToken() + ->shouldNotBeCalled(); + $this->mockFetcher->getCacheKey() + ->shouldBeCalled() + ->willReturn($cacheKey); + $this->mockRequest->withHeader('authorization', 'Bearer ' . $token) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockRequest->reveal()); + + // Run the test. + $cachedFetcher = new FetchAuthTokenCache( + $this->mockFetcher->reveal(), + ['prefix' => $prefix], + $this->mockCache->reveal() + ); + $this->runTestCase($cachedFetcher); + } + + public function testShouldSaveValueInCacheWithSpecifiedPrefix() + { + $prefix = 'test_prefix_'; + $lifetime = '70707'; + $cacheKey = 'myKey'; + $token = '1/abcdef1234567890'; + $cachedValue = ['access_token' => $token]; + $this->mockCacheItem->get() + ->willReturn(null); + $this->mockCacheItem->isHit() + ->willReturn(false); + $this->mockCacheItem->set($cachedValue) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockCacheItem->expiresAfter($lifetime) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockCache->getItem($prefix . $cacheKey) + ->shouldBeCalled() + ->willReturn($this->mockCacheItem->reveal()); + $this->mockCache->save(Argument::type('Psr\Cache\CacheItemInterface')) + ->shouldBeCalled(); + $this->mockFetcher->getCacheKey() + ->shouldBeCalled() + ->willReturn($cacheKey); + $this->mockFetcher->fetchAuthToken(Argument::any()) + ->shouldBeCalledTimes(1) + ->willReturn($cachedValue); + $this->mockRequest->withHeader('authorization', 'Bearer ' . $token) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockRequest->reveal()); + + // Run the test. + $cachedFetcher = new FetchAuthTokenCache( + $this->mockFetcher->reveal(), + ['prefix' => $prefix, 'lifetime' => $lifetime], + $this->mockCache->reveal() + ); + $this->runTestCase($cachedFetcher); + } + + /** + * @dataProvider provideShouldNotifyTokenCallback + */ + public function testShouldNotifyTokenCallback(callable $tokenCallback) + { + $prefix = 'test_prefix_'; + $cacheKey = 'myKey'; + $token = '1/abcdef1234567890'; + $cachedValue = ['access_token' => $token]; + $this->mockCacheItem->get() + ->willReturn(null); + $this->mockCacheItem->isHit() + ->willReturn(false); + $this->mockCacheItem->set($cachedValue) + ->shouldBeCalled() + ->willReturn($this->mockCacheItem->reveal()); + $this->mockCacheItem->expiresAfter(Argument::any()) + ->shouldBeCalled() + ->willReturn($this->mockCacheItem->reveal()); + $this->mockCache->getItem($prefix . $cacheKey) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockCache->save(Argument::type('Psr\Cache\CacheItemInterface')) + ->shouldBeCalled(); + $this->mockFetcher->getCacheKey() + ->willReturn($cacheKey); + $this->mockFetcher->fetchAuthToken(Argument::any()) + ->shouldBeCalledTimes(1) + ->willReturn($cachedValue); + $this->mockFetcher->getLastReceivedToken() + ->willReturn($cachedValue); + $this->mockRequest->withHeader(Argument::any(), Argument::any()) + ->willReturn($this->mockRequest->reveal()); + + MiddlewareCallback::$expectedKey = $this->getValidKeyName($prefix . $cacheKey); + MiddlewareCallback::$expectedValue = $token; + MiddlewareCallback::$called = false; + + // Run the test. + $cachedFetcher = new FetchAuthTokenCache( + $this->mockFetcher->reveal(), + ['prefix' => $prefix], + $this->mockCache->reveal() + ); + $middleware = new AuthTokenMiddleware( + $cachedFetcher, + null, + $tokenCallback + ); + $mock = new MockHandler([new Response(200)]); + $callable = $middleware($mock); + $callable($this->mockRequest->reveal(), ['auth' => 'google_auth']); + $this->assertTrue(MiddlewareCallback::$called); + } + + public function testAddAuthHeadersFromUpdateMetadata() + { + $authResult = [ + 'authorization' => 'Bearer 1/abcdef1234567890', + ]; + + $this->mockFetcher->willImplement(UpdateMetadataInterface::class); + $this->mockFetcher->updateMetadata(Argument::cetera()) + ->shouldBeCalledTimes(1) + ->willReturn($authResult); + $this->mockFetcher->getLastReceivedToken() + ->willReturn(['access_token' => '1/abcdef1234567890']); + + $request = new Request('GET', 'http://foo.com'); + + $middleware = new AuthTokenMiddleware($this->mockFetcher->reveal()); + $mockHandlerCalled = false; + $mock = new MockHandler([function ($request, $options) use ($authResult, &$mockHandlerCalled) { + $this->assertEquals($authResult['authorization'], $request->getHeaderLine('authorization')); + $mockHandlerCalled = true; + return new Response(200); + }]); + $callable = $middleware($mock); + $callable($request, ['auth' => 'google_auth']); + $this->assertTrue($mockHandlerCalled); + } + + public function testOverlappingAddAuthHeadersFromUpdateMetadata() + { + $authHeaders = [ + 'authorization' => 'Bearer 1/abcdef1234567890', + 'x-goog-api-client' => 'extra-value' + ]; + + $request = new Request('GET', 'http://foo.com'); + + $this->mockFetcher->willImplement(UpdateMetadataInterface::class); + $this->mockFetcher->updateMetadata(Argument::cetera()) + ->shouldBeCalledTimes(1) + ->willReturn($authHeaders); + $this->mockFetcher->getLastReceivedToken() + ->willReturn(['access_token' => '1/abcdef1234567890']); + + $middleware = new AuthTokenMiddleware($this->mockFetcher->reveal()); + + $mockHandlerCalled = false; + $mock = new MockHandler([function ($request, $options) use ($authHeaders, &$mockHandlerCalled) { + $this->assertEquals($authHeaders['authorization'], $request->getHeaderLine('authorization')); + $this->assertArrayHasKey('x-goog-api-client', $request->getHeaders()); + $mockHandlerCalled = true; + return new Response(200); + }]); + $callable = $middleware($mock); + $callable($request, ['auth' => 'google_auth']); + $this->assertTrue($mockHandlerCalled); + } + + private function runTestCase($fetcher) + { + $middleware = new AuthTokenMiddleware($fetcher); + $mock = new MockHandler([new Response(200)]); + $callable = $middleware($mock); + $callable($this->mockRequest->reveal(), ['auth' => 'google_auth']); + } + + public function provideShouldNotifyTokenCallback() + { + MiddlewareCallback::$phpunit = $this; + $anonymousFunc = function ($key, $value) { + MiddlewareCallback::staticInvoke($key, $value); + }; + return [ + ['Google\Auth\Tests\Middleware\MiddlewareCallbackFunction'], + ['Google\Auth\Tests\Middleware\MiddlewareCallback::staticInvoke'], + [['Google\Auth\Tests\Middleware\MiddlewareCallback', 'staticInvoke']], + [$anonymousFunc], + [[new MiddlewareCallback(), 'staticInvoke']], + [[new MiddlewareCallback(), 'methodInvoke']], + [new MiddlewareCallback()], + ]; + } +} + +class MiddlewareCallback +{ + public static $phpunit; + public static $expectedKey; + public static $expectedValue; + public static $called = false; + + public function __invoke($key, $value) + { + self::$phpunit->assertEquals(self::$expectedKey, $key); + self::$phpunit->assertEquals(self::$expectedValue, $value); + self::$called = true; + } + + public function methodInvoke($key, $value) + { + return $this($key, $value); + } + + public static function staticInvoke($key, $value) + { + $instance = new self(); + return $instance($key, $value); + } +} + +function MiddlewareCallbackFunction($key, $value) +{ + return MiddlewareCallback::staticInvoke($key, $value); +} diff --git a/Auth/tests/Middleware/ProxyAuthTokenMiddlewareTest.php b/Auth/tests/Middleware/ProxyAuthTokenMiddlewareTest.php new file mode 100644 index 000000000000..3983ade0b653 --- /dev/null +++ b/Auth/tests/Middleware/ProxyAuthTokenMiddlewareTest.php @@ -0,0 +1,125 @@ +mockFetcher = $this->prophesize('Google\Auth\FetchAuthTokenInterface'); + $this->mockRequest = $this->prophesize('GuzzleHttp\Psr7\Request'); + } + + public function testOnlyTouchesWhenAuthConfigScoped() + { + $this->mockFetcher->fetchAuthToken(Argument::any()) + ->willReturn([]); + $this->mockRequest->withHeader()->shouldNotBeCalled(); + + $middleware = new ProxyAuthTokenMiddleware($this->mockFetcher->reveal()); + $mock = new MockHandler([new Response(200)]); + $callable = $middleware($mock); + $callable($this->mockRequest->reveal(), ['proxy_auth' => 'not_google_auth']); + } + + public function testAddsTheTokenAsAnAuthorizationHeader() + { + $authResult = ['id_token' => '1/abcdef1234567890']; + $this->mockFetcher->fetchAuthToken(Argument::any()) + ->shouldBeCalledTimes(1) + ->willReturn($authResult); + $this->mockRequest->withHeader('proxy-authorization', 'Bearer ' . $authResult['id_token']) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockRequest->reveal()); + + // Run the test. + $middleware = new ProxyAuthTokenMiddleware($this->mockFetcher->reveal()); + $mock = new MockHandler([new Response(200)]); + $callable = $middleware($mock); + $callable($this->mockRequest->reveal(), ['proxy_auth' => 'google_auth']); + } + + public function testDoesNotAddAnAuthorizationHeaderOnNoAccessToken() + { + $authResult = ['not_access_token' => '1/abcdef1234567890']; + $this->mockFetcher->fetchAuthToken(Argument::any()) + ->shouldBeCalledTimes(1) + ->willReturn($authResult); + $this->mockRequest->withHeader('proxy-authorization', 'Bearer ') + ->shouldBeCalledTimes(1) + ->willReturn($this->mockRequest->reveal()); + + // Run the test. + $middleware = new ProxyAuthTokenMiddleware($this->mockFetcher->reveal()); + $mock = new MockHandler([new Response(200)]); + $callable = $middleware($mock); + $callable($this->mockRequest->reveal(), ['proxy_auth' => 'google_auth']); + } + + public function testUsesIdTokenWhenAccessTokenDoesNotExist() + { + $token = 'idtoken12345'; + $authResult = ['id_token' => $token]; + $this->mockFetcher->fetchAuthToken(Argument::any()) + ->willReturn($authResult); + $this->mockRequest->withHeader('proxy-authorization', 'Bearer ' . $token) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockRequest->reveal()); + + $middleware = new ProxyAuthTokenMiddleware($this->mockFetcher->reveal()); + $mock = new MockHandler([new Response(200)]); + $callable = $middleware($mock); + $callable($this->mockRequest->reveal(), ['proxy_auth' => 'google_auth']); + } + + public function testGetQuotaProject() + { + $token = 'idtoken12345'; + $authResult = ['id_token' => $token]; + $quotaProject = 'test-quota-project'; + $quotaProjectHeader = GetQuotaProjectInterface::X_GOOG_USER_PROJECT_HEADER; + $this->mockFetcher->willImplement('Google\Auth\GetQuotaProjectInterface'); + $this->mockFetcher->fetchAuthToken(Argument::any()) + ->willReturn($authResult); + $this->mockFetcher->getQuotaProject(Argument::any()) + ->willReturn($quotaProject); + $this->mockRequest->withHeader('proxy-authorization', 'Bearer ' . $token) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockRequest->reveal()); + $this->mockRequest->withHeader($quotaProjectHeader, $quotaProject) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockRequest->reveal()); + $middleware = new ProxyAuthTokenMiddleware($this->mockFetcher->reveal()); + $mock = new MockHandler([new Response(200)]); + $callable = $middleware($mock); + $callable($this->mockRequest->reveal(), ['proxy_auth' => 'google_auth']); + } +} diff --git a/Auth/tests/Middleware/ScopedAccessTokenMiddlewareTest.php b/Auth/tests/Middleware/ScopedAccessTokenMiddlewareTest.php new file mode 100644 index 000000000000..b48f6b181869 --- /dev/null +++ b/Auth/tests/Middleware/ScopedAccessTokenMiddlewareTest.php @@ -0,0 +1,224 @@ +mockCacheItem = $this->prophesize('Psr\Cache\CacheItemInterface'); + $this->mockCache = $this->prophesize('Psr\Cache\CacheItemPoolInterface'); + $this->mockRequest = $this->prophesize('GuzzleHttp\Psr7\Request'); + } + + public function testRequiresScopeAsAStringOrArray() + { + $this->expectException(InvalidArgumentException::class); + + $fakeAuthFunc = function ($unused_scopes) { + return '1/abcdef1234567890'; + }; + new ScopedAccessTokenMiddleware($fakeAuthFunc, new \stdClass()); + } + + public function testAddsTheTokenAsAnAuthorizationHeader() + { + $token = '1/abcdef1234567890'; + $fakeAuthFunc = function ($unused_scopes) use ($token) { + return $token; + }; + $this->mockRequest->withHeader('authorization', 'Bearer ' . $token) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockRequest->reveal()); + + // Run the test + $middleware = new ScopedAccessTokenMiddleware($fakeAuthFunc, self::TEST_SCOPE); + $mock = new MockHandler([new Response(200)]); + $callable = $middleware($mock); + $callable($this->mockRequest->reveal(), ['auth' => 'scoped']); + } + + public function testUsesCachedAuthToken() + { + $cachedValue = '2/abcdef1234567890'; + $fakeAuthFunc = function ($unused_scopes) { + return ''; + }; + $this->mockCacheItem->isHit() + ->shouldBeCalledTimes(1) + ->willReturn(true); + $this->mockCacheItem->get() + ->shouldBeCalledTimes(1) + ->willReturn($cachedValue); + $this->mockCache->getItem($this->getValidKeyName(self::TEST_SCOPE)) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockRequest->withHeader('authorization', 'Bearer ' . $cachedValue) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockRequest->reveal()); + + // Run the test + $middleware = new ScopedAccessTokenMiddleware( + $fakeAuthFunc, + self::TEST_SCOPE, + [], + $this->mockCache->reveal() + ); + $mock = new MockHandler([new Response(200)]); + $callable = $middleware($mock); + $callable($this->mockRequest->reveal(), ['auth' => 'scoped']); + } + + public function testGetsCachedAuthTokenUsingCachePrefix() + { + $prefix = 'test_prefix_'; + $cachedValue = '2/abcdef1234567890'; + $fakeAuthFunc = function ($unused_scopes) { + return ''; + }; + $this->mockCacheItem->isHit() + ->shouldBeCalledTimes(1) + ->willReturn(true); + $this->mockCacheItem->get() + ->shouldBeCalledTimes(1) + ->willReturn($cachedValue); + $this->mockCache->getItem($prefix . $this->getValidKeyName(self::TEST_SCOPE)) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockRequest->withHeader('authorization', 'Bearer ' . $cachedValue) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockRequest->reveal()); + + // Run the test + $middleware = new ScopedAccessTokenMiddleware( + $fakeAuthFunc, + self::TEST_SCOPE, + ['prefix' => $prefix], + $this->mockCache->reveal() + ); + $mock = new MockHandler([new Response(200)]); + $callable = $middleware($mock); + $callable($this->mockRequest->reveal(), ['auth' => 'scoped']); + } + + public function testShouldSaveValueInCache() + { + $token = '2/abcdef1234567890'; + $fakeAuthFunc = function ($unused_scopes) use ($token) { + return $token; + }; + $this->mockCacheItem->isHit() + ->shouldBeCalledTimes(1) + ->willReturn(false); + $this->mockCacheItem->set($token) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockCacheItem->expiresAfter(Argument::any()) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockCache->getItem($this->getValidKeyName(self::TEST_SCOPE)) + ->shouldBeCalledTimes(2) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockCache->save(Argument::type('Psr\Cache\CacheItemInterface')) + ->shouldBeCalled() + ->willReturn(true); + $this->mockRequest->withHeader('authorization', 'Bearer ' . $token) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockRequest->reveal()); + + // Run the test + $middleware = new ScopedAccessTokenMiddleware( + $fakeAuthFunc, + self::TEST_SCOPE, + [], + $this->mockCache->reveal() + ); + $mock = new MockHandler([new Response(200)]); + $callable = $middleware($mock); + $callable($this->mockRequest->reveal(), ['auth' => 'scoped']); + } + + public function testShouldSaveValueInCacheWithCacheOptions() + { + $token = '2/abcdef1234567890'; + $prefix = 'test_prefix_'; + $lifetime = '70707'; + $fakeAuthFunc = function ($unused_scopes) use ($token) { + return $token; + }; + $this->mockCacheItem->isHit() + ->shouldBeCalledTimes(1) + ->willReturn(false); + $this->mockCacheItem->set($token) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockCacheItem->expiresAfter($lifetime) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockCache->getItem($prefix . $this->getValidKeyName(self::TEST_SCOPE)) + ->shouldBeCalledTimes(2) + ->willReturn($this->mockCacheItem->reveal()); + $this->mockCache->save(Argument::type('Psr\Cache\CacheItemInterface')) + ->shouldBeCalled() + ->willReturn(true); + $this->mockRequest->withHeader('authorization', 'Bearer ' . $token) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockRequest->reveal()); + + // Run the test + $middleware = new ScopedAccessTokenMiddleware( + $fakeAuthFunc, + self::TEST_SCOPE, + ['prefix' => $prefix, 'lifetime' => $lifetime], + $this->mockCache->reveal() + ); + $mock = new MockHandler([new Response(200)]); + $callable = $middleware($mock); + $callable($this->mockRequest->reveal(), ['auth' => 'scoped']); + } + + public function testOnlyTouchesWhenAuthConfigScoped() + { + $fakeAuthFunc = function ($unused_scopes) { + return '1/abcdef1234567890'; + }; + $this->mockRequest->withHeader()->shouldNotBeCalled(); + + // Run the test + $middleware = new ScopedAccessTokenMiddleware($fakeAuthFunc, self::TEST_SCOPE); + $mock = new MockHandler([new Response(200)]); + $callable = $middleware($mock); + $callable($this->mockRequest->reveal(), ['auth' => 'not_scoped']); + } +} diff --git a/Auth/tests/Middleware/SimpleMiddlewareTest.php b/Auth/tests/Middleware/SimpleMiddlewareTest.php new file mode 100644 index 000000000000..17e9d4e337d5 --- /dev/null +++ b/Auth/tests/Middleware/SimpleMiddlewareTest.php @@ -0,0 +1,67 @@ +mockRequest = $this->prophesize(Request::class); + } + + public function testApiKey() + { + $testKey = 'foo'; + $params = Query::build(['key' => $testKey]); + + $mockUri = $this->prophesize(UriInterface::class); + $mockUri->getQuery() + ->shouldBeCalledTimes(1) + ->willReturn(''); + $mockUri->withQuery($params) + ->shouldBeCalledTimes(1) + ->willReturn($mockUri->reveal()); + $this->mockRequest->getUri() + ->shouldBeCalledTimes(2) + ->willReturn($mockUri->reveal()); + $this->mockRequest->withUri($mockUri->reveal()) + ->shouldBeCalledTimes(1) + ->willReturn($this->mockRequest->reveal()); + + $middleware = new SimpleMiddleware(['key' => $testKey]); + $mock = new MockHandler([new Response(200)]); + $callable = $middleware($mock); + $callable($this->mockRequest->reveal(), ['auth' => 'simple']); + } +} diff --git a/Auth/tests/OAuth2Test.php b/Auth/tests/OAuth2Test.php new file mode 100644 index 000000000000..d67938d33754 --- /dev/null +++ b/Auth/tests/OAuth2Test.php @@ -0,0 +1,1339 @@ + 'https://accounts.test.org/insecure/url', + 'redirectUri' => 'https://accounts.test.org/redirect/url', + 'clientId' => 'aClientID', + ]; + + private $signingMinimal = [ + 'signingKey' => null, // added in setUp + 'signingAlgorithm' => 'HS256', + 'scope' => 'https://www.googleapis.com/auth/userinfo.profile', + 'issuer' => 'app@example.com', + 'audience' => 'accounts.google.com', + 'clientId' => 'aClientID', + ]; + + private $tokenRequestMinimal = [ + 'tokenCredentialUri' => 'https://tokens_r_us/test', + 'scope' => 'https://www.googleapis.com/auth/userinfo.profile', + 'issuer' => 'app@example.com', + 'audience' => 'accounts.google.com', + 'clientId' => 'aClientID', + ]; + + private $fetchAuthTokenMinimal = [ + 'tokenCredentialUri' => 'https://tokens_r_us/test', + 'scope' => 'https://www.googleapis.com/auth/userinfo.profile', + 'signingKey' => null, // added in setUp + 'signingAlgorithm' => 'HS256', + 'issuer' => 'app@example.com', + 'audience' => 'accounts.google.com', + 'clientId' => 'aClientID', + ]; + + private $verifyIdTokenMinimal = [ + 'scope' => 'https://www.googleapis.com/auth/userinfo.profile', + 'audience' => 'myaccount.on.host.issuer.com', + 'issuer' => 'an.issuer.com', + 'clientId' => 'myaccount.on.host.issuer.com', + ]; + + public function setUp(): void + { + $this->signingMinimal['signingKey'] = str_repeat('x', 256); + $this->fetchAuthTokenMinimal['signingKey'] = file_get_contents( + __DIR__ . '/fixtures/fixtures1/private.pem' + ); + } + + /** + * @group oauth2-authorization-uri + */ + public function testIsNullIfAuthorizationUriIsNull() + { + $this->expectException(InvalidArgumentException::class); + + $o = new OAuth2([]); + $this->assertNull($o->buildFullAuthorizationUri()); + } + + /** + * @group oauth2-authorization-uri + */ + public function testRequiresTheClientId() + { + $this->expectException(InvalidArgumentException::class); + + $o = new OAuth2([ + 'authorizationUri' => 'https://accounts.test.org/auth/url', + 'redirectUri' => 'https://accounts.test.org/redirect/url', + ]); + $o->buildFullAuthorizationUri(); + } + + /** + * @group oauth2-authorization-uri + */ + public function testRequiresTheRedirectUri() + { + $this->expectException(InvalidArgumentException::class); + + $o = new OAuth2([ + 'authorizationUri' => 'https://accounts.test.org/auth/url', + 'clientId' => 'aClientID', + ]); + $o->buildFullAuthorizationUri(); + } + + /** + * @group oauth2-authorization-uri + */ + public function testCannotHavePromptAndApprovalPrompt() + { + $this->expectException(InvalidArgumentException::class); + + $o = new OAuth2([ + 'authorizationUri' => 'https://accounts.test.org/auth/url', + 'clientId' => 'aClientID', + ]); + $o->buildFullAuthorizationUri([ + 'approval_prompt' => 'an approval prompt', + 'prompt' => 'a prompt', + ]); + } + + /** + * @group oauth2-authorization-uri + */ + public function testCannotHaveInsecureAuthorizationUri() + { + $this->expectException(InvalidArgumentException::class); + + $o = new OAuth2([ + 'authorizationUri' => 'http://accounts.test.org/insecure/url', + 'redirectUri' => 'https://accounts.test.org/redirect/url', + 'clientId' => 'aClientID', + ]); + $o->buildFullAuthorizationUri(); + } + + /** + * @group oauth2-authorization-uri + */ + public function testCannotHaveRelativeRedirectUri() + { + $this->expectException(InvalidArgumentException::class); + + $o = new OAuth2([ + 'authorizationUri' => 'http://accounts.test.org/insecure/url', + 'redirectUri' => '/redirect/url', + 'clientId' => 'aClientID', + ]); + $o->buildFullAuthorizationUri(); + } + + /** + * @group oauth2-authorization-uri + */ + public function testAudOrScopeIsRequiredForJwt() + { + $this->expectException(DomainException::class); + $this->expectExceptionMessage('one of scope or aud should not be null'); + $o = new OAuth2([]); + $o->setSigningKey('a key'); + $o->setSigningAlgorithm('RS256'); + $o->setIssuer('an issuer'); + $o->toJwt(); + } + + /** + * @group oauth2-authorization-uri + */ + public function testHasDefaultXXXTypeParams() + { + $o = new OAuth2($this->minimal); + $q = Query::parse($o->buildFullAuthorizationUri()->getQuery()); + $this->assertEquals('code', $q['response_type']); + $this->assertEquals('offline', $q['access_type']); + } + + /** + * @group oauth2-authorization-uri + */ + public function testCanBeUrlObject() + { + $config = array_merge($this->minimal, [ + 'authorizationUri' => Utils::uriFor('https://another/uri'), + ]); + $o = new OAuth2($config); + $this->assertEquals('/uri', $o->buildFullAuthorizationUri()->getPath()); + } + + /** + * @group oauth2-authorization-uri + */ + public function testCanOverrideParams() + { + $overrides = [ + 'access_type' => 'o_access_type', + 'client_id' => 'o_client_id', + 'redirect_uri' => 'o_redirect_uri', + 'response_type' => 'o_response_type', + 'state' => 'o_state', + ]; + $config = array_merge($this->minimal, ['state' => 'the_state']); + $o = new OAuth2($config); + $q = Query::parse($o->buildFullAuthorizationUri($overrides)->getQuery()); + $this->assertEquals('o_access_type', $q['access_type']); + $this->assertEquals('o_client_id', $q['client_id']); + $this->assertEquals('o_redirect_uri', $q['redirect_uri']); + $this->assertEquals('o_response_type', $q['response_type']); + $this->assertEquals('o_state', $q['state']); + } + + /** + * @group oauth2-authorization-uri + */ + public function testAuthorizationUriWithCodeVerifier() + { + $codeVerifier = 'my_code_verifier'; + $expectedCodeChallenge = 'DLIjHQaEUYlb3dD1s35ERX1uDg0eu3_9ggFsQayed5c'; + + // test in constructor + $config = array_merge($this->minimal, ['codeVerifier' => $codeVerifier]); + $o = new OAuth2($config); + $q = Query::parse($o->buildFullAuthorizationUri()->getQuery()); + $this->assertArrayNotHasKey('code_verifier', $q); + $this->assertArrayHasKey('code_challenge', $q); + $this->assertEquals($expectedCodeChallenge, $q['code_challenge']); + $this->assertEquals('S256', $q['code_challenge_method']); + + // test in settter + $o = new OAuth2($this->minimal); + $o->setCodeVerifier($codeVerifier); + $q = Query::parse($o->buildFullAuthorizationUri()->getQuery()); + $this->assertArrayNotHasKey('code_verifier', $q); + $this->assertArrayHasKey('code_challenge', $q); + $this->assertEquals($expectedCodeChallenge, $q['code_challenge']); + $this->assertEquals('S256', $q['code_challenge_method']); + } + + /** + * @group oauth2-authorization-uri + */ + public function testGenerateCodeVerifier() + { + $o = new OAuth2($this->minimal); + $codeVerifier = $o->generateCodeVerifier(); + $this->assertEquals(128, strlen($codeVerifier)); + // The generated code verifier is set on the object + $this->assertEquals($o->getCodeVerifier(), $codeVerifier); + // When it's called again, it generates a new one + $this->assertNotEquals($codeVerifier, $o->generateCodeVerifier()); + // The new code verifier is set on the object + $this->assertNotEquals($codeVerifier, $o->getCodeVerifier()); + } + + /** + * @group oauth2-authorization-uri + */ + public function testIncludesTheScope() + { + $with_strings = array_merge($this->minimal, ['scope' => 'scope1 scope2']); + $o = new OAuth2($with_strings); + $q = Query::parse($o->buildFullAuthorizationUri()->getQuery()); + $this->assertEquals('scope1 scope2', $q['scope']); + + $with_array = array_merge($this->minimal, [ + 'scope' => ['scope1', 'scope2'], + ]); + $o = new OAuth2($with_array); + $q = Query::parse($o->buildFullAuthorizationUri()->getQuery()); + $this->assertEquals('scope1 scope2', $q['scope']); + } + + /** + * @group oauth2-authorization-uri + */ + public function testRedirectUriPostmessageIsAllowed() + { + $o = new OAuth2([ + 'authorizationUri' => 'https://accounts.test.org/insecure/url', + 'redirectUri' => 'postmessage', + 'clientId' => 'aClientID', + ]); + $this->assertEquals('postmessage', $o->getRedirectUri()); + $url = $o->buildFullAuthorizationUri(); + $parts = parse_url((string) $url); + parse_str($parts['query'], $query); + $this->assertArrayHasKey('redirect_uri', $query); + $this->assertEquals('postmessage', $query['redirect_uri']); + } + + /** + * @group oauth2-grant-type + */ + public function testReturnsNullIfCannotBeInferred() + { + $o = new OAuth2($this->minimal); + $this->assertNull($o->getGrantType()); + } + + /** + * @group oauth2-grant-type + */ + public function testInfersAuthorizationCode() + { + $o = new OAuth2($this->minimal); + $o->setCode('an auth code'); + $this->assertEquals('authorization_code', $o->getGrantType()); + } + + /** + * @group oauth2-grant-type + */ + public function testInfersRefreshToken() + { + $o = new OAuth2($this->minimal); + $o->setRefreshToken('a refresh token'); + $this->assertEquals('refresh_token', $o->getGrantType()); + } + + /** + * @group oauth2-grant-type + */ + public function testInfersPassword() + { + $o = new OAuth2($this->minimal); + $o->setPassword('a password'); + $o->setUsername('a username'); + $this->assertEquals('password', $o->getGrantType()); + } + + /** + * @group oauth2-grant-type + */ + public function testInfersJwtBearer() + { + $o = new OAuth2($this->minimal); + $o->setIssuer('an issuer'); + $o->setSigningKey('a key'); + $this->assertEquals( + 'urn:ietf:params:oauth:grant-type:jwt-bearer', + $o->getGrantType() + ); + } + + /** + * @group oauth2-grant-type + */ + public function testSetsKnownTypes() + { + $o = new OAuth2($this->minimal); + foreach (OAuth2::$knownGrantTypes as $t) { + $o->setGrantType($t); + $this->assertEquals($t, $o->getGrantType()); + } + } + + /** + * @group oauth2-grant-type + */ + public function testSetsUrlAsGrantType() + { + $o = new OAuth2($this->minimal); + $o->setGrantType('http://a/grant/url'); + $this->assertEquals('http://a/grant/url', $o->getGrantType()); + } + + /** + * @group oauth2-cache-key + */ + public function testIsNullWithNoScopesOrAudience() + { + $o = new OAuth2($this->minimal); + $this->assertNull($o->getCacheKey()); + } + + /** + * @group oauth2-cache-key + */ + public function testIsScopeIfSingleScope() + { + $o = new OAuth2($this->minimal); + $o->setScope('test/scope/1'); + $this->assertEquals('test/scope/1', $o->getCacheKey()); + } + + /** + * @group oauth2-cache-key + */ + public function testIsAllScopesWhenScopeIsArray() + { + $o = new OAuth2($this->minimal); + $o->setScope(['test/scope/1', 'test/scope/2']); + $this->assertEquals('test/scope/1:test/scope/2', $o->getCacheKey()); + } + + /** + * @group oauth2-cache-key + */ + public function testIsAudienceWhenScopeIsNull() + { + $aud = 'https://drive.googleapis.com'; + $o = new OAuth2($this->minimal); + $o->setAudience($aud); + $this->assertEquals($aud, $o->getCacheKey()); + } + + /** + * @group oauth2-timing + */ + public function testIssuedAtDefaultsToNull() + { + $o = new OAuth2($this->minimal); + $this->assertNull($o->getIssuedAt()); + } + + /** + * @group oauth2-timing + */ + public function testExpiresAtDefaultsToNull() + { + $o = new OAuth2($this->minimal); + $this->assertNull($o->getExpiresAt()); + } + + /** + * @group oauth2-timing + */ + public function testExpiresInDefaultsToNull() + { + $o = new OAuth2($this->minimal); + $this->assertNull($o->getExpiresIn()); + } + + /** + * @group oauth2-timing + */ + public function testSettingExpiresInSetsIssuedAt() + { + $o = new OAuth2($this->minimal); + $this->assertNull($o->getIssuedAt()); + $aShortWhile = 5; + $o->setExpiresIn($aShortWhile); + $this->assertEquals($aShortWhile, $o->getExpiresIn()); + $this->assertNotNull($o->getIssuedAt()); + } + + /** + * @group oauth2-timing + */ + public function testSettingExpiresInSetsExpireAt() + { + $o = new OAuth2($this->minimal); + $this->assertNull($o->getExpiresAt()); + $aShortWhile = 5; + $o->setExpiresIn($aShortWhile); + $this->assertNotNull($o->getExpiresAt()); + $this->assertEquals($aShortWhile, $o->getExpiresAt() - $o->getIssuedAt()); + } + + /** + * @group oauth2-timing + */ + public function testIsNotExpiredByDefault() + { + $o = new OAuth2($this->minimal); + $this->assertFalse($o->isExpired()); + } + + /** + * @group oauth2-timing + */ + public function testIsNotExpiredIfExpiresAtIsOld() + { + $o = new OAuth2($this->minimal); + $o->setExpiresAt(time() - 2); + $this->assertTrue($o->isExpired()); + } + + /** + * @group oauth2-general + */ + public function testFailsOnUnknownSigningAlgorithm() + { + $this->expectException(InvalidArgumentException::class); + + $o = new OAuth2($this->minimal); + $o->setSigningAlgorithm('this is definitely not an algorithm name'); + } + + /** + * @group oauth2-general + */ + public function testAllowsKnownSigningAlgorithms() + { + $o = new OAuth2($this->minimal); + foreach (OAuth2::$knownSigningAlgorithms as $a) { + $o->setSigningAlgorithm($a); + $this->assertEquals($a, $o->getSigningAlgorithm()); + } + } + + /** + * @group oauth2-general + */ + public function testFailsOnRelativeRedirectUri() + { + $this->expectException(InvalidArgumentException::class); + + $o = new OAuth2($this->minimal); + $o->setRedirectUri('/relative/url'); + } + + /** + * @group oauth2-general + */ + public function testAllowsUrnRedirectUri() + { + $urn = 'urn:ietf:wg:oauth:2.0:oob'; + $o = new OAuth2($this->minimal); + $o->setRedirectUri($urn); + $this->assertEquals($urn, $o->getRedirectUri()); + } + + /** + * @group oauth2-jwt + */ + public function testFailsWithMissingAudience() + { + $this->expectException(DomainException::class); + $testConfig = $this->signingMinimal; + unset($testConfig['audience']); + unset($testConfig['scope']); + $o = new OAuth2($testConfig); + $o->toJwt(); + } + + /** + * @group oauth2-jwt + */ + public function testFailsWithMissingIssuer() + { + $this->expectException(DomainException::class); + $testConfig = $this->signingMinimal; + unset($testConfig['issuer']); + $o = new OAuth2($testConfig); + $o->toJwt(); + } + + /** + * @group oauth2-jwt + */ + public function testCanHaveNoScope() + { + $testConfig = $this->signingMinimal; + unset($testConfig['scope']); + $o = new OAuth2($testConfig); + $jwt = $o->toJwt(); + $this->assertTrue(is_string($jwt)); + } + + /** + * @group oauth2-jwt + */ + public function testFailsWithMissingSigningKey() + { + $this->expectException(DomainException::class); + + $testConfig = $this->signingMinimal; + unset($testConfig['signingKey']); + $o = new OAuth2($testConfig); + $o->toJwt(); + } + + /** + * @group oauth2-jwt + */ + public function testFailsWithMissingSigningAlgorithm() + { + $this->expectException(DomainException::class); + $testConfig = $this->signingMinimal; + unset($testConfig['signingAlgorithm']); + $o = new OAuth2($testConfig); + $o->toJwt(); + } + + /** + * @group oauth2-jwt + */ + public function testCanHS256EncodeAValidPayloadWithSigningKeyId() + { + $testConfig = $this->signingMinimal; + $keys = [ + 'example_key_id1' => new Key(str_repeat('y', 256), 'HS256'), + 'example_key_id2' => new Key(str_repeat('z', 256), 'HS256'), + ]; + $testConfig['signingKey'] = $keys['example_key_id2']->getKeyMaterial(); + $testConfig['signingKeyId'] = 'example_key_id2'; + $o = new OAuth2($testConfig); + $payload = $o->toJwt(); + $roundTrip = JWT::decode($payload, $keys); + $this->assertEquals($roundTrip->iss, $testConfig['issuer']); + $this->assertEquals($roundTrip->aud, $testConfig['audience']); + $this->assertEquals($roundTrip->scope, $testConfig['scope']); + } + + /** + * @group oauth2-jwt + */ + public function testFailDecodeWithoutSigningKeyId() + { + $testConfig = $this->signingMinimal; + $keys = [ + 'example_key_id1' => new Key(str_repeat('y', 256), 'HS256'), + 'example_key_id2' => new Key(str_repeat('z', 256), 'HS256'), + ]; + $testConfig['signingKey'] = $keys['example_key_id2']->getKeyMaterial(); + $o = new OAuth2($testConfig); + $payload = $o->toJwt(); + + try { + JWT::decode($payload, $keys); + } catch (\Exception $e) { + // Workaround: In old JWT versions throws DomainException + $this->assertTrue( + ($e instanceof \DomainException || $e instanceof \UnexpectedValueException) + && $e->getMessage() === '"kid" empty, unable to lookup correct key' + ); + return; + } + $this->fail('Expected exception about problem with decode'); + } + + /** + * @group oauth2-jwt + */ + public function testCanHS256EncodeAValidPayload() + { + $testConfig = $this->signingMinimal; + $o = new OAuth2($testConfig); + $payload = $o->toJwt(); + $roundTrip = JWT::decode($payload, new Key($testConfig['signingKey'], 'HS256')); + $this->assertEquals($roundTrip->iss, $testConfig['issuer']); + $this->assertEquals($roundTrip->aud, $testConfig['audience']); + $this->assertEquals($roundTrip->scope, $testConfig['scope']); + } + + /** + * @group oauth2-jwt + */ + public function testCanRS256EncodeAValidPayload() + { + $publicKey = file_get_contents(__DIR__ . '/fixtures/fixtures1/public.pem'); + $privateKey = file_get_contents(__DIR__ . '/fixtures/fixtures1/private.pem'); + $testConfig = $this->signingMinimal; + $o = new OAuth2($testConfig); + $o->setSigningAlgorithm('RS256'); + $o->setSigningKey($privateKey); + $payload = $o->toJwt(); + $roundTrip = JWT::decode($payload, new Key($publicKey, 'RS256')); + $this->assertEquals($roundTrip->iss, $testConfig['issuer']); + $this->assertEquals($roundTrip->aud, $testConfig['audience']); + $this->assertEquals($roundTrip->scope, $testConfig['scope']); + } + + /** + * @group oauth2-jwt + */ + public function testCanHaveAdditionalClaims() + { + $publicKey = file_get_contents(__DIR__ . '/fixtures/fixtures1/public.pem'); + $privateKey = file_get_contents(__DIR__ . '/fixtures/fixtures1/private.pem'); + $testConfig = $this->signingMinimal; + $targetAud = '123@456.com'; + $testConfig['additionalClaims'] = ['target_audience' => $targetAud]; + $o = new OAuth2($testConfig); + $o->setSigningAlgorithm('RS256'); + $o->setSigningKey($privateKey); + $payload = $o->toJwt(); + $roundTrip = JWT::decode($payload, new Key($publicKey, 'RS256')); + $this->assertEquals($roundTrip->target_audience, $targetAud); + } + + /** + * @group oauth2-generate-access-token + */ + public function testFailsIfNoTokenCredentialUri() + { + $this->expectException(DomainException::class); + $testConfig = $this->tokenRequestMinimal; + unset($testConfig['tokenCredentialUri']); + $o = new OAuth2($testConfig); + $o->generateCredentialsRequest(); + } + + /** + * @group oauth2-generate-access-token + */ + public function testFailsIfAuthorizationCodeIsMissing() + { + $this->expectException(DomainException::class); + $testConfig = $this->tokenRequestMinimal; + $testConfig['redirectUri'] = 'https://has/redirect/uri'; + $o = new OAuth2($testConfig); + $o->generateCredentialsRequest(); + } + + /** + * @group oauth2-generate-access-token + */ + public function testGeneratesAuthorizationCodeRequests() + { + $testConfig = $this->tokenRequestMinimal; + $testConfig['redirectUri'] = 'https://has/redirect/uri'; + $o = new OAuth2($testConfig); + $o->setCode('an_auth_code'); + + // Generate the request and confirm that it's correct. + $req = $o->generateCredentialsRequest(); + $this->assertInstanceOf('Psr\Http\Message\RequestInterface', $req); + $this->assertEquals('POST', $req->getMethod()); + $fields = Query::parse((string) $req->getBody()); + $this->assertEquals('authorization_code', $fields['grant_type']); + $this->assertEquals('an_auth_code', $fields['code']); + } + + /** + * @group oauth2-generate-access-token + */ + public function testGeneratesPasswordRequests() + { + $testConfig = $this->tokenRequestMinimal; + $o = new OAuth2($testConfig); + $o->setUsername('a_username'); + $o->setPassword('a_password'); + + // Generate the request and confirm that it's correct. + $req = $o->generateCredentialsRequest(); + $this->assertInstanceOf('Psr\Http\Message\RequestInterface', $req); + $this->assertEquals('POST', $req->getMethod()); + $fields = Query::parse((string) $req->getBody()); + $this->assertEquals('password', $fields['grant_type']); + $this->assertEquals('a_password', $fields['password']); + $this->assertEquals('a_username', $fields['username']); + } + + /** + * @group oauth2-generate-access-token + */ + public function testGeneratesRefreshTokenRequests() + { + $testConfig = $this->tokenRequestMinimal; + $o = new OAuth2($testConfig); + $o->setRefreshToken('a_refresh_token'); + + // Generate the request and confirm that it's correct. + $req = $o->generateCredentialsRequest(); + $this->assertInstanceOf('Psr\Http\Message\RequestInterface', $req); + $this->assertEquals('POST', $req->getMethod()); + $fields = Query::parse((string) $req->getBody()); + $this->assertEquals('refresh_token', $fields['grant_type']); + $this->assertEquals('a_refresh_token', $fields['refresh_token']); + } + + /** + * @group oauth2-generate-access-token + */ + public function testClientSecretAddedIfSetForAuthorizationCodeRequests() + { + $testConfig = $this->tokenRequestMinimal; + $testConfig['clientSecret'] = 'a_client_secret'; + $testConfig['redirectUri'] = 'https://has/redirect/uri'; + $o = new OAuth2($testConfig); + $o->setCode('an_auth_code'); + $request = $o->generateCredentialsRequest(); + $fields = Query::parse((string) $request->getBody()); + $this->assertEquals('a_client_secret', $fields['client_secret']); + } + + /** + * @group oauth2-generate-access-token + */ + public function testClientSecretAddedIfSetForRefreshTokenRequests() + { + $testConfig = $this->tokenRequestMinimal; + $testConfig['clientSecret'] = 'a_client_secret'; + $o = new OAuth2($testConfig); + $o->setRefreshToken('a_refresh_token'); + $request = $o->generateCredentialsRequest(); + $fields = Query::parse((string) $request->getBody()); + $this->assertEquals('a_client_secret', $fields['client_secret']); + } + + /** + * @group oauth2-generate-access-token + */ + public function testClientSecretAddedIfSetForPasswordRequests() + { + $testConfig = $this->tokenRequestMinimal; + $testConfig['clientSecret'] = 'a_client_secret'; + $o = new OAuth2($testConfig); + $o->setUsername('a_username'); + $o->setPassword('a_password'); + $request = $o->generateCredentialsRequest(); + $fields = Query::parse((string) $request->getBody()); + $this->assertEquals('a_client_secret', $fields['client_secret']); + } + + /** + * @group oauth2-generate-access-token + */ + public function testGeneratesAssertionRequests() + { + $testConfig = $this->tokenRequestMinimal; + $o = new OAuth2($testConfig); + $o->setSigningKey(str_repeat('z', 256)); + $o->setSigningAlgorithm('HS256'); + + // Generate the request and confirm that it's correct. + $req = $o->generateCredentialsRequest(); + $this->assertInstanceOf('Psr\Http\Message\RequestInterface', $req); + $this->assertEquals('POST', $req->getMethod()); + $fields = Query::parse((string) $req->getBody()); + $this->assertEquals(OAuth2::JWT_URN, $fields['grant_type']); + $this->assertArrayHasKey('assertion', $fields); + } + + /** + * @group oauth2-generate-access-token + */ + public function testGeneratesExtendedRequests() + { + $testConfig = $this->tokenRequestMinimal; + $o = new OAuth2($testConfig); + $o->setGrantType('urn:my_test_grant_type'); + $o->setExtensionParams(['my_param' => 'my_value']); + + // Generate the request and confirm that it's correct. + $req = $o->generateCredentialsRequest(); + $this->assertInstanceOf('Psr\Http\Message\RequestInterface', $req); + $this->assertEquals('POST', $req->getMethod()); + $fields = Query::parse((string) $req->getBody()); + $this->assertEquals('my_value', $fields['my_param']); + $this->assertEquals('urn:my_test_grant_type', $fields['grant_type']); + } + + /** + * @group oauth2-generate-access-token + */ + public function testTokenUriWithCodeVerifier() + { + $codeVerifier = 'my_code_verifier'; + + // test in constructor + $config = array_merge($this->tokenRequestMinimal, [ + 'codeVerifier' => $codeVerifier, + ]); + $o = new OAuth2($config); + $o->setCode('abc123'); + $req = $o->generateCredentialsRequest(); + $fields = Query::parse((string) $req->getBody()); + $this->assertArrayHasKey('code_verifier', $fields); + $this->assertEquals($codeVerifier, $fields['code_verifier']); + + // test in settter + $o = new OAuth2($this->tokenRequestMinimal); + $o->setCode('abc123'); + $o->setCodeVerifier($codeVerifier); + $req = $o->generateCredentialsRequest(); + $q = Query::parse((string) $req->getBody()); + $this->assertArrayHasKey('code_verifier', $q); + $this->assertEquals($codeVerifier, $q['code_verifier']); + } + + /** + * @group oauth2-fetch-auth-token + */ + public function testFailsOn400() + { + $this->expectException(\GuzzleHttp\Exception\ClientException::class); + + $testConfig = $this->fetchAuthTokenMinimal; + $httpHandler = $this->getHandler([ + new Response(400), + ]); + $o = new OAuth2($testConfig); + $o->fetchAuthToken($httpHandler); + } + + /** + * @group oauth2-fetch-auth-token + */ + public function testFailsOn500() + { + $this->expectException(\GuzzleHttp\Exception\ServerException::class); + + $testConfig = $this->fetchAuthTokenMinimal; + $httpHandler = $this->getHandler([ + new Response(500), + ]); + $o = new OAuth2($testConfig); + $o->fetchAuthToken($httpHandler); + } + + /** + * @group oauth2-fetch-auth-token + */ + public function testFailsOnNoContentTypeIfResponseIsNotJSON() + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid JSON response'); + + $testConfig = $this->fetchAuthTokenMinimal; + $notJson = '{"foo": , this is cannot be passed as json" "bar"}'; + $httpHandler = $this->getHandler([ + new Response(200, [], Utils::streamFor($notJson)), + ]); + $o = new OAuth2($testConfig); + $o->fetchAuthToken($httpHandler); + } + + /** + * @group oauth2-fetch-auth-token + */ + public function testFetchesJsonResponseOnNoContentTypeOK() + { + $testConfig = $this->fetchAuthTokenMinimal; + $json = '{"foo": "bar"}'; + $httpHandler = $this->getHandler([ + new Response(200, [], Utils::streamFor($json)), + ]); + $o = new OAuth2($testConfig); + $tokens = $o->fetchAuthToken($httpHandler); + $this->assertEquals($tokens['foo'], 'bar'); + } + + /** + * @group oauth2-fetch-auth-token + */ + public function testFetchesFromFormEncodedResponseOK() + { + $testConfig = $this->fetchAuthTokenMinimal; + $json = 'foo=bar&spice=nice'; + $httpHandler = $this->getHandler([ + new Response( + 200, + ['Content-Type' => 'application/x-www-form-urlencoded'], + Utils::streamFor($json) + ), + ]); + $o = new OAuth2($testConfig); + $tokens = $o->fetchAuthToken($httpHandler); + $this->assertEquals($tokens['foo'], 'bar'); + $this->assertEquals($tokens['spice'], 'nice'); + } + + /** + * @group oauth2-fetch-auth-token + */ + public function testUpdatesTokenFieldsOnFetch() + { + $testConfig = $this->fetchAuthTokenMinimal; + $wanted_updates = [ + 'expires_at' => '1', + 'expires_in' => '57', + 'issued_at' => '2', + 'access_token' => 'an_access_token', + 'id_token' => 'an_id_token', + 'refresh_token' => 'a_refresh_token', + 'scope' => 'scope1 scope2', + ]; + $json = json_encode($wanted_updates); + $httpHandler = $this->getHandler([ + new Response(200, [], Utils::streamFor($json)), + ]); + $o = new OAuth2($testConfig); + $this->assertNull($o->getExpiresAt()); + $this->assertNull($o->getExpiresIn()); + $this->assertNull($o->getIssuedAt()); + $this->assertNull($o->getAccessToken()); + $this->assertNull($o->getIdToken()); + $this->assertNull($o->getRefreshToken()); + $tokens = $o->fetchAuthToken($httpHandler); + $this->assertEquals(1, $o->getExpiresAt()); + $this->assertEquals(57, $o->getExpiresIn()); + $this->assertEquals(2, $o->getIssuedAt()); + $this->assertEquals('an_access_token', $o->getAccessToken()); + $this->assertEquals('an_id_token', $o->getIdToken()); + $this->assertEquals('a_refresh_token', $o->getRefreshToken()); + $this->assertEquals('scope1 scope2', $o->getGrantedScope()); + } + + /** + * @group oauth2-fetch-auth-token + */ + public function testUpdatesTokenFieldsOnFetchMissingRefreshToken() + { + $testConfig = $this->fetchAuthTokenMinimal; + $testConfig['refresh_token'] = 'a_refresh_token'; + $wanted_updates = [ + 'expires_at' => '1', + 'expires_in' => '57', + 'issued_at' => '2', + 'access_token' => 'an_access_token', + 'id_token' => 'an_id_token', + ]; + $json = json_encode($wanted_updates); + $httpHandler = $this->getHandler([ + new Response(200, [], Utils::streamFor($json)), + ]); + $o = new OAuth2($testConfig); + $this->assertNull($o->getExpiresAt()); + $this->assertNull($o->getExpiresIn()); + $this->assertNull($o->getIssuedAt()); + $this->assertNull($o->getAccessToken()); + $this->assertNull($o->getIdToken()); + $this->assertEquals('a_refresh_token', $o->getRefreshToken()); + $tokens = $o->fetchAuthToken($httpHandler); + $this->assertEquals(1, $o->getExpiresAt()); + $this->assertEquals(57, $o->getExpiresIn()); + $this->assertEquals(2, $o->getIssuedAt()); + $this->assertEquals('an_access_token', $o->getAccessToken()); + $this->assertEquals('an_id_token', $o->getIdToken()); + $this->assertEquals('a_refresh_token', $o->getRefreshToken()); + } + + /** + * @dataProvider provideGetLastReceivedToken + * @group oauth2-fetch-auth-token + */ + public function testGetLastReceivedToken( + $updateToken, + $expectedToken = null + ) { + $testConfig = $this->fetchAuthTokenMinimal; + $o = new OAuth2($testConfig); + $o->updateToken($updateToken); + $this->assertEquals( + $expectedToken ?: $updateToken, + $o->getLastReceivedToken() + ); + } + + public function provideGetLastReceivedToken() + { + $time = time(); + return [ + [ + ['access_token' => 'abc'], + ['access_token' => 'abc', 'expires_at' => null], + ], + [ + ['access_token' => 'abc', 'invalid-field' => 'foo'], + ['access_token' => 'abc', 'expires_at' => null], + ], + [ + ['access_token' => 'abc', 'expires_at' => 1234567890], + ['access_token' => 'abc', 'expires_at' => 1234567890], + ], + [ + ['id_token' => 'def'], + ['id_token' => 'def', 'expires_at' => null], + ], + [ + ['id_token' => 'def', 'expires_at' => 1234567890], + ['id_token' => 'def', 'expires_at' => 1234567890], + ], + [ + [ + 'access_token' => 'abc', + 'expires_in' => 3600, + 'issued_at' => $time + ], + [ + 'access_token' => 'abc', + 'expires_at' => $time + 3600, + 'expires_in' => 3600, + 'issued_at' => $time + ], + ], + [ + ['access_token' => 'abc', 'issued_at' => 1234567890], + [ + 'access_token' => 'abc', + 'expires_at' => null, + 'issued_at' => 1234567890 + ], + ], + [ + ['access_token' => 'abc', 'refresh_token' => 'xyz'], + [ + 'access_token' => 'abc', + 'expires_at' => null, + 'refresh_token' => 'xyz' + ], + ], + ]; + } + + /** + * @group oauth2-verify-id-token + */ + public function testFailsIfIdTokenIsInvalid() + { + $this->expectException(UnexpectedValueException::class); + + $publicKey = file_get_contents(__DIR__ . '/fixtures/fixtures1/public.pem'); + $testConfig = $this->verifyIdTokenMinimal; + $not_a_jwt = 'not a jot'; + $o = new OAuth2($testConfig); + $o->setIdToken($not_a_jwt); + $o->verifyIdToken($publicKey, ['RS256']); + } + + /** + * @group oauth2-verify-id-token + */ + public function testFailsIfAudienceIsMissing() + { + $this->expectException(DomainException::class); + + $publicKey = file_get_contents(__DIR__ . '/fixtures/fixtures1/public.pem'); + $privateKey = file_get_contents(__DIR__ . '/fixtures/fixtures1/private.pem'); + $testConfig = $this->verifyIdTokenMinimal; + $now = time(); + $origIdToken = [ + 'issuer' => $testConfig['issuer'], + 'exp' => $now + 65, // arbitrary + 'iat' => $now, + ]; + $o = new OAuth2($testConfig); + $jwtIdToken = JWT::encode($origIdToken, $privateKey, 'RS256'); + $o->setIdToken($jwtIdToken); + $o->verifyIdToken($publicKey, ['RS256']); + } + + /** + * @group oauth2-verify-id-token + */ + public function testFailsIfAudienceIsWrong() + { + $this->expectException(DomainException::class); + + $publicKey = file_get_contents(__DIR__ . '/fixtures/fixtures1/public.pem'); + $privateKey = file_get_contents(__DIR__ . '/fixtures/fixtures1/private.pem'); + $now = time(); + $testConfig = $this->verifyIdTokenMinimal; + $origIdToken = [ + 'aud' => 'a different audience', + 'iss' => $testConfig['issuer'], + 'exp' => $now + 65, // arbitrary + 'iat' => $now, + ]; + $o = new OAuth2($testConfig); + $jwtIdToken = JWT::encode($origIdToken, $privateKey, 'RS256'); + $o->setIdToken($jwtIdToken); + $o->verifyIdToken($publicKey, ['RS256']); + } + + /** + * @group oauth2-verify-id-token + */ + public function testFailsWithStringPublicKeyAndAllowedAlgsGreaterThanOne() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('To have multiple allowed algorithms'); + + $publicKey = file_get_contents(__DIR__ . '/fixtures/fixtures1/public.pem'); + $testConfig = $this->verifyIdTokenMinimal; + $not_a_jwt = 'not a jot'; + $o = new OAuth2($testConfig); + $o->setIdToken($not_a_jwt); + $o->verifyIdToken($publicKey, ['RS256', 'ES256']); + } + + /** + * @group oauth2-verify-id-token + */ + public function testFailsWithStringPublicKeyAndNoAllowedAlgs() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('When allowed algorithms is empty'); + + $publicKey = file_get_contents(__DIR__ . '/fixtures/fixtures1/public.pem'); + $testConfig = $this->verifyIdTokenMinimal; + $not_a_jwt = 'not a jot'; + $o = new OAuth2($testConfig); + $o->setIdToken($not_a_jwt); + $o->verifyIdToken($publicKey, []); + } + + /** + * @group oauth2-verify-id-token + */ + public function testFailsWithStringInPublicKeyArrayAndNoAllowedAlgs() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('When allowed algorithms is empty'); + + $publicKey = file_get_contents(__DIR__ . '/fixtures/fixtures1/public.pem'); + $testConfig = $this->verifyIdTokenMinimal; + $not_a_jwt = 'not a jot'; + $o = new OAuth2($testConfig); + $o->setIdToken($not_a_jwt); + $o->verifyIdToken([ + new Key($publicKey, 'RS256'), + $publicKey, + ], []); + } + + /** + * @group oauth2-verify-id-token + */ + public function testFailsWithInvalidTypeForAllowedAlgs() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('allowed algorithms must be a string or array'); + + $publicKey = file_get_contents(__DIR__ . '/fixtures/fixtures1/public.pem'); + $testConfig = $this->verifyIdTokenMinimal; + $not_a_jwt = 'not a jot'; + $o = new OAuth2($testConfig); + $o->setIdToken($not_a_jwt); + $o->verifyIdToken($publicKey, 123); + } + + /** + * @group oauth2-verify-id-token + */ + public function testShouldReturnAValidIdToken() + { + $publicKey = file_get_contents(__DIR__ . '/fixtures/fixtures1/public.pem'); + $privateKey = file_get_contents(__DIR__ . '/fixtures/fixtures1/private.pem'); + $testConfig = $this->verifyIdTokenMinimal; + $now = time(); + $origIdToken = [ + 'aud' => $testConfig['audience'], + 'iss' => $testConfig['issuer'], + 'exp' => $now + 65, // arbitrary + 'iat' => $now, + ]; + $o = new OAuth2($testConfig); + $alg = 'RS256'; + $jwtIdToken = JWT::encode($origIdToken, $privateKey, $alg); + $o->setIdToken($jwtIdToken); + + // Test with array alg + $roundTrip = $o->verifyIdToken($publicKey, [$alg]); + $this->assertEquals($origIdToken['aud'], $roundTrip->aud); + + // Test with string alg + $roundTrip2 = $o->verifyIdToken($publicKey, $alg); + $this->assertEquals($origIdToken['aud'], $roundTrip2->aud); + } +} + +class OAuth2StsTest extends TestCase +{ + use ProphecyTrait; + + private $publicKey; + private $privateKey; + private $stsMinimal = [ + 'tokenCredentialUri' => 'https://tokens_r_us/test', + 'subjectTokenType' => 'urn:ietf:params:aws:token-type:aws4_request', + ]; + + public function testStsGrantType() + { + $credentialSource = $this->prophesize(ExternalAccountCredentialSourceInterface::class); + $o = new OAuth2($this->stsMinimal + ['subjectTokenFetcher' => $credentialSource->reveal()]); + $this->assertEquals(OAuth2::STS_URN, $o->getGrantType()); + } + + public function testStsCredentialsRequestMinimal() + { + $credentialSource = $this->prophesize(ExternalAccountCredentialSourceInterface::class); + $credentialSource->fetchSubjectToken(null) + ->shouldBeCalledOnce() + ->willReturn('xyz'); + $o = new OAuth2($this->stsMinimal + ['subjectTokenFetcher' => $credentialSource->reveal()]); + $request = $o->generateCredentialsRequest(); + $this->assertEquals('POST', $request->getMethod()); + $this->assertEquals($this->stsMinimal['tokenCredentialUri'], (string) $request->getUri()); + parse_str((string) $request->getBody(), $requestParams); + $this->assertCount(4, $requestParams); + $this->assertEquals(OAuth2::STS_URN, $requestParams['grant_type']); + $this->assertEquals('xyz', $requestParams['subject_token']); + $this->assertEquals($this->stsMinimal['subjectTokenType'], $requestParams['subject_token_type']); + } + + public function testStsCredentialsRequestFull() + { + $credentialSource = $this->prophesize(ExternalAccountCredentialSourceInterface::class); + $credentialSource->fetchSubjectToken(null) + ->shouldBeCalledOnce() + ->willReturn('xyz'); + $stsMinimal = $this->stsMinimal + [ + 'subjectTokenFetcher' => $credentialSource->reveal(), + 'resource' => 'abc', + 'scope' => ['scope1', 'scope2'], + 'audience' => 'def', + 'actorToken' => '123', + 'actorTokenType' => 'urn:ietf:params:oauth:token-type:access_token', + ]; + $o = new OAuth2($stsMinimal); + $request = $o->generateCredentialsRequest(); + $this->assertEquals('POST', $request->getMethod()); + $this->assertEquals($this->stsMinimal['tokenCredentialUri'], (string) $request->getUri()); + parse_str((string) $request->getBody(), $requestParams); + + $this->assertCount(9, $requestParams); + $this->assertEquals(OAuth2::STS_URN, $requestParams['grant_type']); + $this->assertEquals('xyz', $requestParams['subject_token']); + $this->assertEquals($stsMinimal['subjectTokenType'], $requestParams['subject_token_type']); + $this->assertEquals($stsMinimal['resource'], $requestParams['resource']); + $this->assertEquals('scope1 scope2', $requestParams['scope']); + $this->assertEquals($stsMinimal['audience'], $requestParams['audience']); + $this->assertEquals($stsMinimal['actorToken'], $requestParams['actor_token']); + $this->assertEquals($stsMinimal['actorTokenType'], $requestParams['actor_token_type']); + } +} diff --git a/Auth/tests/ObservabilityMetricsTest.php b/Auth/tests/ObservabilityMetricsTest.php new file mode 100644 index 000000000000..4f436699e42b --- /dev/null +++ b/Auth/tests/ObservabilityMetricsTest.php @@ -0,0 +1,258 @@ +langAndVersion = sprintf( + 'gl-php/%s auth/%s', + PHP_VERSION, + $updateMetadataTraitImpl::getVersion() + ); + $this->jsonTokens = json_encode(['access_token' => '1/abdef1234567890', 'expires_in' => '57']); + } + + /** + * @dataProvider tokenRequestType + */ + public function testGCECredentials($scope, $targetAudience, $requestTypeHeaderValue) + { + $handlerCalled = false; + $jsonTokens = $this->jsonTokens; + $handler = $this->getHandler([ + new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']), + function ( + $request, + $options + ) use ( + $jsonTokens, + &$handlerCalled, + $requestTypeHeaderValue + ) { + $handlerCalled = true; + // This confirms that token endpoint requests have proper observability metric headers + $this->assertStringContainsString( + sprintf('%s %s cred-type/mds', $this->langAndVersion, $requestTypeHeaderValue), + $request->getHeaderLine(self::$headerKey) + ); + return new Response(200, [], Utils::streamFor($jsonTokens)); + } + ]); + + $gceCred = new GCECredentials(null, $scope, $targetAudience); + $this->assertUpdateMetadata($gceCred, $handler, 'mds', $handlerCalled); + } + + /** + * @dataProvider tokenRequestType + */ + public function testServiceAccountCredentials($scope, $targetAudience, $requestTypeHeaderValue) + { + $keyFile = __DIR__ . '/fixtures/fixtures3/service_account_credentials.json'; + $handlerCalled = false; + $handler = $this->getCustomHandler('sa', $requestTypeHeaderValue, $handlerCalled); + + $sa = new ServiceAccountCredentials( + $scope, + $keyFile, + null, + $targetAudience + ); + $this->assertUpdateMetadata($sa, $handler, 'sa', $handlerCalled); + } + + /** + * ServiceAccountJwtAccessCredentials creates the jwt token within library hence + * they don't have any observability metrics header check for token endpoint requests. + */ + public function testServiceAccountJwtAccessCredentials() + { + $keyFile = __DIR__ . '/fixtures/fixtures3/service_account_credentials.json'; + $saJwt = new ServiceAccountJwtAccessCredentials($keyFile, 'exampleScope'); + $metadata = $saJwt->updateMetadata([self::$headerKey => ['foo']], null, null); + $this->assertArrayHasKey(self::$headerKey, $metadata); + + // This confirms that service usage requests have proper observability metric headers + $this->assertStringContainsString( + sprintf('foo cred-type/jwt'), + $metadata[self::$headerKey][0] + ); + } + + public function testImpersonatedServiceAccountCredentials() + { + $keyFile = __DIR__ . '/fixtures/fixtures5/.config/gcloud/application_default_credentials.json'; + $handlerCalled = false; + $responseFromIam = json_encode(['accessToken' => '1/abdef1234567890', 'expireTime' => '2024-01-01T00:00:00Z']); + $handler = $this->getHandler([ + $this->getExpectedRequest('imp', 'auth-request-type/at', $handlerCalled, $this->jsonTokens), + $this->getExpectedRequest('imp', 'auth-request-type/at', $handlerCalled, $responseFromIam), + ]); + + $impersonatedCred = new ImpersonatedServiceAccountCredentials('exampleScope', $keyFile); + $this->assertUpdateMetadata($impersonatedCred, $handler, 'imp', $handlerCalled); + } + + public function testImpersonatedServiceAccountCredentialsWithIdTokens() + { + $keyFile = __DIR__ . '/fixtures/fixtures5/.config/gcloud/application_default_credentials.json'; + $handlerCalled = false; + $responseFromIam = json_encode(['token' => '1/abdef1234567890']); + $handler = $this->getHandler([ + $this->getExpectedRequest('imp', 'auth-request-type/at', $handlerCalled, $this->jsonTokens), + $this->getExpectedRequest('imp', 'auth-request-type/it', $handlerCalled, $responseFromIam), + ]); + + $impersonatedCred = new ImpersonatedServiceAccountCredentials(null, $keyFile, 'test-target-audience'); + $this->assertUpdateMetadata($impersonatedCred, $handler, 'imp', $handlerCalled); + } + + /** + * UserRefreshCredentials haven't enabled identity token support hence + * they don't have 'auth-request-type/it' observability metric header check. + */ + public function testUserRefreshCredentials() + { + $keyFile = __DIR__ . '/fixtures/fixtures2/gcloud.json'; + $handlerCalled = false; + $handler = $this->getCustomHandler('u', 'auth-request-type/at', $handlerCalled); + + $userRefreshCred = new UserRefreshCredentials('exampleScope', $keyFile); + $this->assertUpdateMetadata($userRefreshCred, $handler, 'u', $handlerCalled); + } + + public function testUserRefreshCredentialsWithIdTokens() + { + $keyFile = __DIR__ . '/fixtures/fixtures2/gcloud.json'; + $handlerCalled = false; + $handler = $this->getCustomHandler('u', 'auth-request-type/it', $handlerCalled); + + $userRefreshCred = new UserRefreshCredentials(null, $keyFile, 'test-target-audience'); + $this->assertUpdateMetadata($userRefreshCred, $handler, 'u', $handlerCalled); + } + + /** + * Invokes the 'updateMetadata' method of cred fetcher with empty metadata argument + * and asserts for proper service api usage observability metrics header. + */ + private function assertUpdateMetadata($cred, $handler, $credShortform, &$handlerCalled) + { + $metadata = $cred->updateMetadata([self::$headerKey => ['foo']], null, $handler); + $this->assertArrayHasKey(self::$headerKey, $metadata); + + // This confirms that service usage requests have proper observability metric headers + $this->assertStringContainsString( + sprintf('foo cred-type/%s', $credShortform), + $metadata[self::$headerKey][0] + ); + + $this->assertTrue($handlerCalled); + } + + /** + * @param string $credShortform The short form of the credential type + * used in observability metric header value. + * @param string $requestTypeHeaderValue Expected header value of the form + * 'auth-request-type/<>' + * @param bool $handlerCalled Reference to the handlerCalled flag asserted later + * in the test. + * @return callable + */ + private function getCustomHandler($credShortform, $requestTypeHeaderValue, &$handlerCalled) + { + return $this->getHandler([ + $this->getExpectedRequest( + $credShortform, + $requestTypeHeaderValue, + $handlerCalled, + $this->jsonTokens + ) + ]); + } + + /** + * @param string $credShortform The short form of the credential type + * used in observability metric header value. + * @param string $requestTypeHeaderValue Expected header value of the form + * 'auth-request-type/<>' + * @param bool $handlerCalled Reference to the handlerCalled flag asserted later + * in the test. + * @param string $jsonTokens The json tokens to be returned in the response. + * @return callable + */ + private function getExpectedRequest( + string $credShortform, + string $requestTypeHeaderValue, + bool &$handlerCalled, + string $jsonTokens + ): callable { + return function ( + $request, + $options + ) use ( + $jsonTokens, + &$handlerCalled, + $requestTypeHeaderValue, + $credShortform + ) { + $handlerCalled = true; + // This confirms that token endpoint requests have proper observability metric headers + $this->assertStringContainsString( + sprintf('%s %s cred-type/%s', $this->langAndVersion, $requestTypeHeaderValue, $credShortform), + $request->getHeaderLine(self::$headerKey) + ); + return new Response(200, [], Utils::streamFor($jsonTokens)); + }; + } + + public function tokenRequestType() + { + return [ + ['someScope', null, 'auth-request-type/at'], + [null, 'someTargetAudience', 'auth-request-type/it'], + ]; + } +} diff --git a/Auth/tests/ServiceAccountSignerTraitTest.php b/Auth/tests/ServiceAccountSignerTraitTest.php new file mode 100644 index 000000000000..7cf6be49d507 --- /dev/null +++ b/Auth/tests/ServiceAccountSignerTraitTest.php @@ -0,0 +1,76 @@ +signBlob(self::STRING_TO_SIGN, $useOpenSsl); + + $this->assertEquals(implode('', $this->signedString), $res); + } + + public function useOpenSsl() + { + return [[true], [false]]; + } +} + +class ServiceAccountSignerTraitImpl +{ + use ServiceAccountSignerTrait; + + private $auth; + + public function __construct($signingKey) + { + $this->auth = new AuthStub(); + $this->auth->signingKey = $signingKey; + } +} + +class AuthStub +{ + public $signingKey; + + public function getSigningKey() + { + return $this->signingKey; + } +} diff --git a/Auth/tests/bootstrap.php b/Auth/tests/bootstrap.php new file mode 100644 index 000000000000..5c18eee75284 --- /dev/null +++ b/Auth/tests/bootstrap.php @@ -0,0 +1,20 @@ +", "/dev/null"]} \ No newline at end of file diff --git a/Auth/tests/fixtures/fixtures4/invalidjson/.secureConnect/context_aware_metadata.json b/Auth/tests/fixtures/fixtures4/invalidjson/.secureConnect/context_aware_metadata.json new file mode 100644 index 000000000000..8c8155222d38 --- /dev/null +++ b/Auth/tests/fixtures/fixtures4/invalidjson/.secureConnect/context_aware_metadata.json @@ -0,0 +1 @@ +this is not json \ No newline at end of file diff --git a/Auth/tests/fixtures/fixtures4/invalidkey/.secureConnect/context_aware_metadata.json b/Auth/tests/fixtures/fixtures4/invalidkey/.secureConnect/context_aware_metadata.json new file mode 100644 index 000000000000..58fec93495a6 --- /dev/null +++ b/Auth/tests/fixtures/fixtures4/invalidkey/.secureConnect/context_aware_metadata.json @@ -0,0 +1 @@ +{"this-is-the-wrong-key":["echo","foo"]} \ No newline at end of file diff --git a/Auth/tests/fixtures/fixtures4/invalidvalue/.secureConnect/context_aware_metadata.json b/Auth/tests/fixtures/fixtures4/invalidvalue/.secureConnect/context_aware_metadata.json new file mode 100644 index 000000000000..05f393d92c55 --- /dev/null +++ b/Auth/tests/fixtures/fixtures4/invalidvalue/.secureConnect/context_aware_metadata.json @@ -0,0 +1 @@ +{"cert_provider_command":"this is the wrong value"} \ No newline at end of file diff --git a/Auth/tests/fixtures/fixtures4/valid/.secureConnect/context_aware_metadata.json b/Auth/tests/fixtures/fixtures4/valid/.secureConnect/context_aware_metadata.json new file mode 100644 index 000000000000..43e3b48ea125 --- /dev/null +++ b/Auth/tests/fixtures/fixtures4/valid/.secureConnect/context_aware_metadata.json @@ -0,0 +1 @@ +{"cert_provider_command":["echo","foo"]} \ No newline at end of file diff --git a/Auth/tests/fixtures/fixtures5/.config/gcloud b/Auth/tests/fixtures/fixtures5/.config/gcloud new file mode 120000 index 000000000000..38d21f0a3cb2 --- /dev/null +++ b/Auth/tests/fixtures/fixtures5/.config/gcloud @@ -0,0 +1 @@ +../gcloud/ \ No newline at end of file diff --git a/Auth/tests/fixtures/fixtures5/gcloud/application_default_credentials.json b/Auth/tests/fixtures/fixtures5/gcloud/application_default_credentials.json new file mode 100644 index 000000000000..8fb762c00c9c --- /dev/null +++ b/Auth/tests/fixtures/fixtures5/gcloud/application_default_credentials.json @@ -0,0 +1,10 @@ +{ + "type": "impersonated_service_account", + "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service_account_name@namespace.iam.gserviceaccount.com:generateAccessToken", + "source_credentials": { + "client_id": "client123", + "client_secret": "clientSecret123", + "refresh_token": "refreshToken123", + "type": "authorized_user" + } +} diff --git a/Auth/tests/fixtures/fixtures6/aws_credentials.json b/Auth/tests/fixtures/fixtures6/aws_credentials.json new file mode 100644 index 000000000000..db8269146714 --- /dev/null +++ b/Auth/tests/fixtures/fixtures6/aws_credentials.json @@ -0,0 +1,13 @@ +{ + "type": "external_account", + "audience": "//iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/byoid-pool-php/providers/PROJECT_ID", + "subject_token_type": "urn:ietf:params:aws:token-type:aws4_request", + "token_url": "https://sts.googleapis.com/v1/token", + "credential_source": { + "environment_id": "aws1", + "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", + "regional_cred_verification_url": "https://sts.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15" + }, + "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/byoid-test@cicpclientproj.iam.gserviceaccount.com:generateAccessToken" + } diff --git a/Auth/tests/fixtures/fixtures6/executable_credentials.json b/Auth/tests/fixtures/fixtures6/executable_credentials.json new file mode 100644 index 000000000000..e33affc43528 --- /dev/null +++ b/Auth/tests/fixtures/fixtures6/executable_credentials.json @@ -0,0 +1,14 @@ +{ + "type": "external_account", + "audience": "//iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/byoid-pool-php/providers/PROJECT_ID", + "subject_token_type": "urn:ietf:params:aws:token-type:aws4_request", + "token_url": "https://sts.googleapis.com/v1/token", + "credential_source": { + "executable": { + "command": "cmd.sh", + "timeout_millis": 5000, + "output_file": "test" + } + }, + "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/byoid-test@cicpclientproj.iam.gserviceaccount.com:generateAccessToken" + } diff --git a/Auth/tests/fixtures/fixtures6/file_credentials.json b/Auth/tests/fixtures/fixtures6/file_credentials.json new file mode 100644 index 000000000000..55fd6bf39c6c --- /dev/null +++ b/Auth/tests/fixtures/fixtures6/file_credentials.json @@ -0,0 +1,9 @@ +{ + "type": "external_account", + "audience": "some_audience", + "subject_token_type": "access_token", + "token_url": "https://sts.googleapis.com/v1/token", + "credential_source": { + "file": "some_file.txt" + } + } diff --git a/Auth/tests/fixtures/fixtures6/url_credentials.json b/Auth/tests/fixtures/fixtures6/url_credentials.json new file mode 100644 index 000000000000..1a7681d8af5f --- /dev/null +++ b/Auth/tests/fixtures/fixtures6/url_credentials.json @@ -0,0 +1,9 @@ +{ + "type": "external_account", + "audience": "some_audience", + "subject_token_type": "access_token", + "token_url": "https://sts.googleapis.com/v1/token", + "credential_source": { + "url": "https://some_url.io" + } + } diff --git a/Auth/tests/fixtures/fixtures7/env.json b/Auth/tests/fixtures/fixtures7/env.json new file mode 100644 index 000000000000..c4ae2ca9510d --- /dev/null +++ b/Auth/tests/fixtures/fixtures7/env.json @@ -0,0 +1 @@ +{"type": "env"} diff --git a/Auth/tests/fixtures/fixtures7/getenv.json b/Auth/tests/fixtures/fixtures7/getenv.json new file mode 100644 index 000000000000..aa6d2b2a0b4c --- /dev/null +++ b/Auth/tests/fixtures/fixtures7/getenv.json @@ -0,0 +1 @@ +{"type": "getenv"} diff --git a/Auth/tests/mocks/AppIdentityService.php b/Auth/tests/mocks/AppIdentityService.php new file mode 100644 index 000000000000..8d73238be751 --- /dev/null +++ b/Auth/tests/mocks/AppIdentityService.php @@ -0,0 +1,38 @@ + 'xyz', + 'expiration_time' => '2147483646', + ]; + public static $serviceAccountName; + public static $applicationId; + + public static function getAccessToken($scope) + { + self::$scope = $scope; + + return self::$accessToken; + } + + public static function signForApp($stringToSign) + { + return [ + 'signature' => 'Signed: ' . $stringToSign + ]; + } + + public static function getServiceAccountName() + { + return self::$serviceAccountName; + } + + public static function getApplicationId() + { + return self::$applicationId; + } +} diff --git a/Auth/tests/mocks/TestFileCacheItemPool.php b/Auth/tests/mocks/TestFileCacheItemPool.php new file mode 100644 index 000000000000..de9f510c698f --- /dev/null +++ b/Auth/tests/mocks/TestFileCacheItemPool.php @@ -0,0 +1,195 @@ +cacheDir = $cacheDir; + } + + /** + * {@inheritdoc} + * + * @return CacheItemInterface The corresponding Cache Item. + */ + public function getItem($key): CacheItemInterface + { + return current($this->getItems([$key])); // @phpstan-ignore-line + } + + /** + * {@inheritdoc} + * + * @return iterable + * A traversable collection of Cache Items keyed by the cache keys of + * each item. A Cache item will be returned for each key, even if that + * key is not found. However, if no keys are specified then an empty + * traversable MUST be returned instead. + */ + public function getItems(array $keys = []): iterable + { + $items = []; + foreach ($keys as $key) { + if ($this->hasItem($key)) { + $items[$key] = unserialize(file_get_contents($this->cacheDir . '/' . $key)); + } else { + $items[$key] = new TypedItem($key); + } + } + + return $items; + } + + /** + * {@inheritdoc} + * + * @return bool + * True if item exists in the cache, false otherwise. + */ + public function hasItem($key): bool + { + $this->isValidKey($key); + + return file_exists($this->cacheDir . '/' . $key) + && unserialize(file_get_contents($this->cacheDir . '/' . $key))->isHit(); + } + + /** + * {@inheritdoc} + * + * @return bool + * True if the pool was successfully cleared. False if there was an error. + */ + public function clear(): bool + { + $this->deferredItems = []; + + return true; + } + + /** + * {@inheritdoc} + * + * @return bool + * True if the item was successfully removed. False if there was an error. + */ + public function deleteItem($key): bool + { + return $this->deleteItems([$key]); + } + + /** + * {@inheritdoc} + * + * @return bool + * True if the items were successfully removed. False if there was an error. + */ + public function deleteItems(array $keys): bool + { + array_walk($keys, [$this, 'isValidKey']); + + foreach ($keys as $key) { + unlink($this->cacheDir . '/' . $key); + } + + return true; + } + + /** + * {@inheritdoc} + * + * @return bool + * True if the item was successfully persisted. False if there was an error. + */ + public function save(CacheItemInterface $item): bool + { + if (!is_dir($this->cacheDir)) { + mkdir($this->cacheDir, 0777, true); + } + file_put_contents($this->cacheDir . '/' . $item->getKey(), serialize($item)); + + return true; + } + + /** + * {@inheritdoc} + * + * @return bool + * False if the item could not be queued or if a commit was attempted and failed. True otherwise. + */ + public function saveDeferred(CacheItemInterface $item): bool + { + $this->deferredItems[$item->getKey()] = $item; + + return true; + } + + /** + * {@inheritdoc} + * + * @return bool + * True if all not-yet-saved items were successfully saved or there were none. False otherwise. + */ + public function commit(): bool + { + foreach ($this->deferredItems as $item) { + $this->save($item); + } + + $this->deferredItems = []; + + return true; + } + + /** + * Determines if the provided key is valid. + * + * @param string $key + * @return bool + * @throws InvalidArgumentException + */ + private function isValidKey($key) + { + $invalidCharacters = '{}()/\\\\@:'; + + if (!is_string($key) || preg_match("#[$invalidCharacters]#", $key)) { + throw new InvalidArgumentException('The provided key is not valid: ' . var_export($key, true)); + } + + return true; + } +} diff --git a/Auth/tests/mocks/test_file_cache_separate_process.php b/Auth/tests/mocks/test_file_cache_separate_process.php new file mode 100644 index 000000000000..536e8d5ed1e8 --- /dev/null +++ b/Auth/tests/mocks/test_file_cache_separate_process.php @@ -0,0 +1,51 @@ +cacheKey = $cacheKey; + } + + public function getUniverseDomain(): string + { + throw new \Exception('Should not be called!'); + } + + public function getCacheKey() + { + return $this->cacheKey; + } + + // no op + public function fetchAuthToken(?callable $httpHandle = null) + { + } + // no op + public function getLastReceivedToken() + { + } +}; + +$cacheFetcher = new FetchAuthTokenCache( + $fetcher, + ['cacheUniverseDomain' => true], + $cache +); + +echo $cacheFetcher->getUniverseDomain(); diff --git a/Auth/tests/phpstan-autoload.php b/Auth/tests/phpstan-autoload.php new file mode 100644 index 000000000000..50d10a923b6c --- /dev/null +++ b/Auth/tests/phpstan-autoload.php @@ -0,0 +1,23 @@ +namespaces = $namespaces; $this->componentDependencies = []; - // All components depend on google/auth + if ($this->name !== 'auth') { - $this->componentDependencies[] = new Component('auth', self::ROOT_DIR . '/dev/vendor/google/auth'); + $this->componentDependencies[] = new Component('Auth'); } + // find dependencies which are google/cloud components foreach ($composerJson['require'] ?? [] as $name => $version) { if ($componentName = key(array_filter( diff --git a/dev/src/DocFx/Node/InterfaceNode.php b/dev/src/DocFx/Node/InterfaceNode.php index bddf65a070e0..a5b03efc7b43 100644 --- a/dev/src/DocFx/Node/InterfaceNode.php +++ b/dev/src/DocFx/Node/InterfaceNode.php @@ -38,7 +38,7 @@ public function determineImplementingClasses(array $pageNodes): void { // Project root components $componentDirs = array_map('realpath', glob(__DIR__ . '/../../../../*/src', GLOB_ONLYDIR)); - $componentDirs[] = realpath(__DIR__ . '/../../../vendor/google/auth'); + $componentDirs[] = __DIR__ . '/../../../vendor/google/cloud/Auth/src'; $finder = new ComposerFinder(); $finder diff --git a/dev/tests/Unit/Command/DocFxCommandTest.php b/dev/tests/Unit/Command/DocFxCommandTest.php index b06dc71d8047..33868760a24c 100644 --- a/dev/tests/Unit/Command/DocFxCommandTest.php +++ b/dev/tests/Unit/Command/DocFxCommandTest.php @@ -122,7 +122,7 @@ public function testDocFxIterfaceFile() '--xml' => self::$fixturesDir . '/phpdoc/auth.xml', '--out' => $tmpDir = sys_get_temp_dir() . '/' . rand(), '--metadata-version' => '1.0.0', - '--path' => __DIR__ . '/../../../vendor/google/auth', + '--path' => __DIR__ . '/../../../../Auth', '--with-cache' => true, ]); diff --git a/dev/tests/Unit/DocFx/PageTest.php b/dev/tests/Unit/DocFx/PageTest.php index c1715a0e1637..e8b758f9fcf3 100644 --- a/dev/tests/Unit/DocFx/PageTest.php +++ b/dev/tests/Unit/DocFx/PageTest.php @@ -122,7 +122,7 @@ public function testInterfacePage() __DIR__ . '/../../fixtures/phpdoc/auth.xml', 'Google\Auth', 'Google Auth', - __DIR__ . '/../../../vendor/google/auth', + __DIR__ . '/../../../../Auth', [], ); @@ -141,7 +141,7 @@ public function testDeprecatedNodes() __DIR__ . '/../../fixtures/phpdoc/auth.xml', 'Google\Auth', 'Google Auth', - __DIR__ . '/../../../vendor/google/auth', + __DIR__ . '/../../../../Auth', [], ); diff --git a/dev/tests/fixtures/docfx/Vision/docs.metadata b/dev/tests/fixtures/docfx/Vision/docs.metadata index 92821a6fc116..7ed906854baf 100644 --- a/dev/tests/fixtures/docfx/Vision/docs.metadata +++ b/dev/tests/fixtures/docfx/Vision/docs.metadata @@ -8,7 +8,6 @@ language: "php" distribution_name: "google/cloud-vision" github_repository: "googleapis/google-cloud-php-vision" issue_tracker: "https://github.com/googleapis/google-cloud-php-vision/issues" -xrefs: "devsite://php/auth" xrefs: "devsite://php/cloud-core" xrefs: "devsite://php/gax" xrefs: "devsite://php/common-protos" diff --git a/phpstan.neon.dist b/phpstan.neon.dist index c3b85fee1ceb..f4957603895a 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -12,6 +12,8 @@ parameters: - Logging/src/LogMessageProcessor/MonologV3MessageProcessor.php # ignore GAX because we implement a stricter phpstan.neon.dist there - Gax + # ignore Auth because we implement a stricter phpstan.neon.dist there + - Auth ignoreErrors: # Protobuf constant classes sometimes contain multiple values for one array key - identifier: array.duplicateKey diff --git a/phpunit.xml.dist b/phpunit.xml.dist index e4cb968d15ba..f94c12e3fbb3 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -23,6 +23,7 @@ */tests/Unit + Auth/tests dev/tests/Unit Core