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