From c90f1d11b25319a87e36c990eedf8662aa2f8b66 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Wed, 27 Jan 2021 09:08:02 +0000 Subject: [PATCH 01/10] chore: release 0.23.1-SNAPSHOT (#544) :robot: I have created a release \*beep\* \*boop\* --- ### Updating meta-information for bleeding-edge SNAPSHOT release. --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). --- appengine/pom.xml | 2 +- bom/pom.xml | 2 +- credentials/pom.xml | 2 +- oauth2_http/pom.xml | 2 +- pom.xml | 2 +- versions.txt | 12 ++++++------ 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/appengine/pom.xml b/appengine/pom.xml index c486e5f69..d5cc23d4d 100644 --- a/appengine/pom.xml +++ b/appengine/pom.xml @@ -5,7 +5,7 @@ com.google.auth google-auth-library-parent - 0.23.0 + 0.23.1-SNAPSHOT ../pom.xml diff --git a/bom/pom.xml b/bom/pom.xml index 9f87f73d5..eeb502dec 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -3,7 +3,7 @@ 4.0.0 com.google.auth google-auth-library-bom - 0.23.0 + 0.23.1-SNAPSHOT pom Google Auth Library for Java BOM diff --git a/credentials/pom.xml b/credentials/pom.xml index eb92df55d..f8469d3bc 100644 --- a/credentials/pom.xml +++ b/credentials/pom.xml @@ -4,7 +4,7 @@ com.google.auth google-auth-library-parent - 0.23.0 + 0.23.1-SNAPSHOT ../pom.xml diff --git a/oauth2_http/pom.xml b/oauth2_http/pom.xml index 23bd7635f..7774aad37 100644 --- a/oauth2_http/pom.xml +++ b/oauth2_http/pom.xml @@ -5,7 +5,7 @@ com.google.auth google-auth-library-parent - 0.23.0 + 0.23.1-SNAPSHOT ../pom.xml diff --git a/pom.xml b/pom.xml index eb1acdb12..e913f9c6e 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 com.google.auth google-auth-library-parent - 0.23.0 + 0.23.1-SNAPSHOT pom Google Auth Library for Java Client libraries providing authentication and diff --git a/versions.txt b/versions.txt index d65e1200c..b26eded59 100644 --- a/versions.txt +++ b/versions.txt @@ -1,9 +1,9 @@ # Format: # module:released-version:current-version -google-auth-library:0.23.0:0.23.0 -google-auth-library-bom:0.23.0:0.23.0 -google-auth-library-parent:0.23.0:0.23.0 -google-auth-library-appengine:0.23.0:0.23.0 -google-auth-library-credentials:0.23.0:0.23.0 -google-auth-library-oauth2-http:0.23.0:0.23.0 +google-auth-library:0.23.0:0.23.1-SNAPSHOT +google-auth-library-bom:0.23.0:0.23.1-SNAPSHOT +google-auth-library-parent:0.23.0:0.23.1-SNAPSHOT +google-auth-library-appengine:0.23.0:0.23.1-SNAPSHOT +google-auth-library-credentials:0.23.0:0.23.1-SNAPSHOT +google-auth-library-oauth2-http:0.23.0:0.23.1-SNAPSHOT From 4ec16f935b6b5a98f1ba3f3b3ef974842a8a1c8e Mon Sep 17 00:00:00 2001 From: Elliotte Rusty Harold Date: Thu, 28 Jan 2021 23:12:48 +0000 Subject: [PATCH 02/10] chore: revert back to correct license (BSD) (#545) Automation accidentally changed this file. --- LICENSE | 230 +++++++------------------------------------------------ synth.py | 1 + 2 files changed, 29 insertions(+), 202 deletions(-) diff --git a/LICENSE b/LICENSE index d64569567..2acb3d750 100644 --- a/LICENSE +++ b/LICENSE @@ -1,202 +1,28 @@ - - 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. +Copyright 2014, Google Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/synth.py b/synth.py index c768f338f..901e0d000 100644 --- a/synth.py +++ b/synth.py @@ -16,6 +16,7 @@ import synthtool.languages.java as java java.common_templates(excludes=[ + "LICENSE", "README.md", "java.header", "checkstyle.xml", From 1fa9e1cde3ea8cb7ce0e250192dd154c07211997 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Thu, 28 Jan 2021 22:14:29 -0800 Subject: [PATCH 03/10] build: migrate to flakybot (#548) --- .kokoro/build.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.kokoro/build.sh b/.kokoro/build.sh index 9fff1279b..ec1061bc1 100755 --- a/.kokoro/build.sh +++ b/.kokoro/build.sh @@ -110,8 +110,8 @@ bash .kokoro/coerce_logs.sh if [[ "${ENABLE_BUILD_COP}" == "true" ]] then - chmod +x ${KOKORO_GFILE_DIR}/linux_amd64/buildcop - ${KOKORO_GFILE_DIR}/linux_amd64/buildcop -repo=googleapis/google-auth-library-java + chmod +x ${KOKORO_GFILE_DIR}/linux_amd64/flakybot + ${KOKORO_GFILE_DIR}/linux_amd64/flakybot -repo=googleapis/google-auth-library-java fi echo "exiting with ${RETURN_CODE}" From 91df992970a2146790637cc8f34dec7936d16617 Mon Sep 17 00:00:00 2001 From: WhiteSource Renovate Date: Tue, 16 Feb 2021 15:53:13 +0100 Subject: [PATCH 04/10] chore(deps): update dependency junit:junit to v4.13.2 (#569) --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index e913f9c6e..318881f05 100644 --- a/pom.xml +++ b/pom.xml @@ -60,7 +60,7 @@ UTF-8 1.38.1 - 4.13.1 + 4.13.2 30.1-android 1.9.84 1.7.4 From 7eccff1b19ccb700bc47243e5f85abcbe271bd23 Mon Sep 17 00:00:00 2001 From: Yoshi Automation Bot Date: Thu, 18 Feb 2021 09:00:05 -0800 Subject: [PATCH 05/10] build(java): run linkage monitor as GitHub action build: migrate to flakybot chore: update cloud-rad buckets (#574) This PR was generated using Autosynth. :rainbow: Synth log will be available here: https://source.cloud.google.com/results/invocations/9dfb129c-4122-4560-83af-5a01ba16cbc9/targets - [ ] To automatically regenerate this PR, check this box. Source-Link: https://github.com/googleapis/synthtool/commit/b416a7befcdbc42de41cf387dcf428f894fb812b Source-Link: https://github.com/googleapis/synthtool/commit/f327d3b657a63ae4a8efd7f011a15eacae36b59c Source-Link: https://github.com/googleapis/synthtool/commit/2414b817065726eae0bc525346c7e874f969369d Source-Link: https://github.com/googleapis/synthtool/commit/692715c0f23a7bb3bfbbaa300f7620ddfa8c47e5 Source-Link: https://github.com/googleapis/synthtool/commit/140ba24a136c63e7f10a998a63e7898aed63ea7d Source-Link: https://github.com/googleapis/synthtool/commit/e935c9ecb47da0f2e054f5f1845f7cf7c95fa625 Source-Link: https://github.com/googleapis/synthtool/commit/5de29e9434b63ea6d7e46dc348521c62969af1a1 Source-Link: https://github.com/googleapis/synthtool/commit/d1bb9173100f62c0cfc8f3138b62241e7f47ca6a --- .github/workflows/auto-release.yaml | 6 +-- .github/workflows/ci.yaml | 6 ++- .kokoro/linkage-monitor.sh | 46 ---------------------- .kokoro/release/publish_javadoc.cfg | 9 ++--- .kokoro/release/publish_javadoc.sh | 2 +- .kokoro/release/publish_javadoc11.cfg | 30 +++++++++++++++ .kokoro/release/publish_javadoc11.sh | 55 +++++++++++++++++++++++++++ synth.metadata | 8 ++-- 8 files changed, 101 insertions(+), 61 deletions(-) delete mode 100755 .kokoro/linkage-monitor.sh create mode 100644 .kokoro/release/publish_javadoc11.cfg create mode 100755 .kokoro/release/publish_javadoc11.sh diff --git a/.github/workflows/auto-release.yaml b/.github/workflows/auto-release.yaml index 2b6cdbc97..7c8816a7d 100644 --- a/.github/workflows/auto-release.yaml +++ b/.github/workflows/auto-release.yaml @@ -4,7 +4,7 @@ name: auto-release jobs: approve: runs-on: ubuntu-latest - if: contains(github.head_ref, 'release-v') + if: contains(github.head_ref, 'release-please') steps: - uses: actions/github-script@v3 with: @@ -16,8 +16,8 @@ jobs: return; } - // only approve PRs like "chore: release " - if ( !context.payload.pull_request.title.startsWith("chore: release") ) { + // only approve PRs like "chore(master): release " + if ( !context.payload.pull_request.title.startsWith("chore(master): release") ) { return; } diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 683022075..def8b3a2c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -54,7 +54,11 @@ jobs: with: java-version: 8 - run: java -version - - run: .kokoro/linkage-monitor.sh + - name: Install artifacts to local Maven repository + run: .kokoro/build.sh + shell: bash + - name: Validate any conflicts with regard to com.google.cloud:libraries-bom (latest release) + uses: GoogleCloudPlatform/cloud-opensource-java/linkage-monitor@v1-linkagemonitor lint: runs-on: ubuntu-latest steps: diff --git a/.kokoro/linkage-monitor.sh b/.kokoro/linkage-monitor.sh deleted file mode 100755 index 759ab4e2c..000000000 --- a/.kokoro/linkage-monitor.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/bin/bash -# Copyright 2019 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -set -eo pipefail -# Display commands being run. -set -x - -## Get the directory of the build script -scriptDir=$(realpath $(dirname "${BASH_SOURCE[0]}")) -## cd to the parent directory, i.e. the root of the git repo -cd ${scriptDir}/.. - -# include common functions -source ${scriptDir}/common.sh - -# Print out Java version -java -version -echo ${JOB_TYPE} - -# attempt to install 3 times with exponential backoff (starting with 10 seconds) -retry_with_backoff 3 10 \ - mvn install -B -V \ - -DskipTests=true \ - -Dclirr.skip=true \ - -Denforcer.skip=true \ - -Dmaven.javadoc.skip=true \ - -Dgcloud.download.skip=true - -# Kokoro job cloud-opensource-java/ubuntu/linkage-monitor-gcs creates this JAR -JAR=linkage-monitor-latest-all-deps.jar -curl -v -O "https://storage.googleapis.com/cloud-opensource-java-linkage-monitor/${JAR}" - -# Fails if there's new linkage errors compared with baseline -java -jar ${JAR} com.google.cloud:libraries-bom diff --git a/.kokoro/release/publish_javadoc.cfg b/.kokoro/release/publish_javadoc.cfg index c39d4346a..713676594 100644 --- a/.kokoro/release/publish_javadoc.cfg +++ b/.kokoro/release/publish_javadoc.cfg @@ -7,10 +7,10 @@ env_vars: { value: "docs-staging" } +# cloud-rad staging env_vars: { key: "STAGING_BUCKET_V2" - value: "docs-staging-v2" - # Production will be at: docs-staging-v2 + value: "docs-staging-v2-staging" } env_vars: { @@ -26,7 +26,4 @@ before_action { keyname: "docuploader_service_account" } } -} - -# Downloads docfx doclet resource. This will be in ${KOKORO_GFILE_DIR}/ -gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/docfx" \ No newline at end of file +} \ No newline at end of file diff --git a/.kokoro/release/publish_javadoc.sh b/.kokoro/release/publish_javadoc.sh index 045d64b43..ba83b7f23 100755 --- a/.kokoro/release/publish_javadoc.sh +++ b/.kokoro/release/publish_javadoc.sh @@ -71,7 +71,7 @@ python3 -m docuploader create-metadata \ --version ${VERSION} \ --language java -# upload docs +# upload docs to staging bucket python3 -m docuploader upload . \ --credentials ${CREDENTIALS} \ --staging-bucket ${STAGING_BUCKET_V2} diff --git a/.kokoro/release/publish_javadoc11.cfg b/.kokoro/release/publish_javadoc11.cfg new file mode 100644 index 000000000..888585f7a --- /dev/null +++ b/.kokoro/release/publish_javadoc11.cfg @@ -0,0 +1,30 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +# cloud-rad production +env_vars: { + key: "STAGING_BUCKET_V2" + value: "docs-staging-v2" +} + +# Configure the docker image for kokoro-trampoline +env_vars: { + key: "TRAMPOLINE_IMAGE" + value: "gcr.io/cloud-devrel-kokoro-resources/java11" +} + +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/google-auth-library-java/.kokoro/release/publish_javadoc11.sh" +} + +before_action { + fetch_keystore { + keystore_resource { + keystore_config_id: 73713 + keyname: "docuploader_service_account" + } + } +} + +# Downloads docfx doclet resource. This will be in ${KOKORO_GFILE_DIR}/ +gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/docfx" diff --git a/.kokoro/release/publish_javadoc11.sh b/.kokoro/release/publish_javadoc11.sh new file mode 100755 index 000000000..83beb9a06 --- /dev/null +++ b/.kokoro/release/publish_javadoc11.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# Copyright 2021 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. + +set -eo pipefail + +if [[ -z "${CREDENTIALS}" ]]; then + CREDENTIALS=${KOKORO_KEYSTORE_DIR}/73713_docuploader_service_account +fi + +if [[ -z "${STAGING_BUCKET_V2}" ]]; then + echo "Need to set STAGING_BUCKET_V2 environment variable" + exit 1 +fi + +# work from the git root directory +pushd $(dirname "$0")/../../ + +# install docuploader package +python3 -m pip install gcp-docuploader + +# compile all packages +mvn clean install -B -q -DskipTests=true + +export NAME=google-auth-library +export VERSION=$(grep ${NAME}: versions.txt | cut -d: -f3) + +# V3 generates docfx yml from javadoc +# generate yml +mvn clean site -B -q -P docFX + +pushd target/docfx-yml + +# create metadata +python3 -m docuploader create-metadata \ + --name ${NAME} \ + --version ${VERSION} \ + --language java + +# upload yml to production bucket +python3 -m docuploader upload . \ + --credentials ${CREDENTIALS} \ + --staging-bucket ${STAGING_BUCKET_V2} \ + --destination-prefix docfx- diff --git a/synth.metadata b/synth.metadata index de556ec1f..eb23e2a58 100644 --- a/synth.metadata +++ b/synth.metadata @@ -4,14 +4,14 @@ "git": { "name": ".", "remote": "https://github.com/googleapis/google-auth-library-java.git", - "sha": "427963e04702d8b73eca5ed555539b11bbe97342" + "sha": "91df992970a2146790637cc8f34dec7936d16617" } }, { "git": { "name": "synthtool", "remote": "https://github.com/googleapis/synthtool.git", - "sha": "3816b080296d4d52975079fd26c110dd26ba25af" + "sha": "b416a7befcdbc42de41cf387dcf428f894fb812b" } } ], @@ -38,7 +38,6 @@ ".kokoro/continuous/java8.cfg", ".kokoro/continuous/readme.cfg", ".kokoro/dependencies.sh", - ".kokoro/linkage-monitor.sh", ".kokoro/nightly/common.cfg", ".kokoro/nightly/integration.cfg", ".kokoro/nightly/java11.cfg", @@ -70,6 +69,8 @@ ".kokoro/release/promote.sh", ".kokoro/release/publish_javadoc.cfg", ".kokoro/release/publish_javadoc.sh", + ".kokoro/release/publish_javadoc11.cfg", + ".kokoro/release/publish_javadoc11.sh", ".kokoro/release/snapshot.cfg", ".kokoro/release/snapshot.sh", ".kokoro/release/stage.cfg", @@ -77,7 +78,6 @@ ".kokoro/trampoline.sh", "CODE_OF_CONDUCT.md", "CONTRIBUTING.md", - "LICENSE", "codecov.yaml" ] } \ No newline at end of file From 2142db314666f298071ae30a7419b00d48d87476 Mon Sep 17 00:00:00 2001 From: Leo <39062083+lsirac@users.noreply.github.com> Date: Thu, 18 Feb 2021 11:35:09 -0800 Subject: [PATCH 06/10] docs: add instructions for using workload identity federation (#564) * docs: add instructions for using workload identity federation * docs: address review comments --- README.md | 219 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 219 insertions(+) diff --git a/README.md b/README.md index 9d11eef77..7ef7021a4 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,12 @@ access tokens - `ServiceAccountJwtAccessCredentials`: credentials for a Service Account - use JSON Web Token (JWT) directly in the request metadata to provide authorization - `UserCredentials`: credentials for a user identity and consent +- `ExternalAccountCredentials`: base class for credentials using workload identity federation to +access Google Cloud resources from non-Google Cloud platforms +- `IdentityPoolCredentials`: credentials using workload identity federation to access Google Cloud +resources from Microsoft Azure or any identity provider that supports OpenID Connect (OIDC) +- `AwsCredentials`: credentials using workload identity federation to access Google Cloud resources +from Amazon Web Services (AWS) To get Application Default Credentials use `GoogleCredentials.getApplicationDefault()` or `GoogleCredentials.getApplicationDefault(HttpTransportFactory)`. These methods return the @@ -159,6 +165,219 @@ for (Bucket b : storage_service.list().iterateAll()) System.out.println(b); ``` +### 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, your workload can impersonate a service account. +This lets the external workload access Google Cloud resources directly, eliminating the maintenance +and security burden associated with service account keys. + +#### Accessing resources from AWS + +In order to access Google Cloud resources from Amazon Web Services (AWS), the following requirements +are needed: +- A workload identity pool needs to be created. +- AWS needs to be added as an identity provider in the workload identity pool (the Google [organization policy](https://cloud.google.com/iam/docs/manage-workload-identity-pools-providers#restrict) needs to allow federation from AWS). +- Permission to impersonate a service account needs to be granted to the external identity. + +Follow the detailed [instructions](https://cloud.google.com/iam/docs/access-resources-aws) on how to +configure workload identity federation from AWS. + +After configuring the AWS provider to impersonate a service account, a credential configuration file +needs to be generated. Unlike service account credential files, the generated credential +configuration file contains non-sensitive metadata to instruct the library on how to +retrieve external subject tokens and exchange them for service account access tokens. +The configuration file can be generated by using the [gcloud CLI](https://cloud.google.com/sdk/). + +To generate the AWS workload identity configuration, run the following command: + +```bash +# Generate an AWS configuration file. +gcloud iam workload-identity-pools create-cred-config \ + projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$POOL_ID/providers/$AWS_PROVIDER_ID \ + --service-account $SERVICE_ACCOUNT_EMAIL \ + --aws \ + --output-file /path/to/generated/config.json +``` + +Where the following variables need to be substituted: +- `$PROJECT_NUMBER`: The Google Cloud project number. +- `$POOL_ID`: The workload identity pool ID. +- `$AWS_PROVIDER_ID`: The AWS provider ID. +- `$SERVICE_ACCOUNT_EMAIL`: The email of the service account to impersonate. + +This generates the configuration file in the specified output file. + +You can now [use the Auth library](#using-external-identities) to call Google Cloud +resources from AWS. + +#### Access resources from Microsoft Azure + +In order to access Google Cloud resources from Microsoft Azure, the following requirements are +needed: +- A workload identity pool needs to be created. +- Azure needs to be added as an identity provider in the workload identity pool (the Google [organization policy](https://cloud.google.com/iam/docs/manage-workload-identity-pools-providers#restrict) needs to allow federation from Azure). +- The Azure tenant needs to be configured for identity federation. +- Permission to impersonate a service account needs to be granted to the external identity. + +Follow the detailed [instructions](https://cloud.google.com/iam/docs/access-resources-azure) on how +to configure workload identity federation from Microsoft Azure. + +After configuring the Azure provider to impersonate a service account, a credential configuration +file needs to be generated. Unlike service account credential files, the generated credential +configuration file contains non-sensitive metadata to instruct the library on how to +retrieve external subject tokens and exchange them for service account access tokens. +The configuration file can be generated by using the [gcloud CLI](https://cloud.google.com/sdk/). + +To generate the Azure workload identity configuration, run the following command: + +```bash +# Generate an Azure configuration file. +gcloud iam workload-identity-pools create-cred-config \ + projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$POOL_ID/providers/$AZURE_PROVIDER_ID \ + --service-account $SERVICE_ACCOUNT_EMAIL \ + --azure \ + --output-file /path/to/generated/config.json +``` + +Where the following variables need to be substituted: +- `$PROJECT_NUMBER`: The Google Cloud project number. +- `$POOL_ID`: The workload identity pool ID. +- `$AZURE_PROVIDER_ID`: The Azure provider ID. +- `$SERVICE_ACCOUNT_EMAIL`: The email of the service account to impersonate. + +This generates the configuration file in the specified output file. + +You can now [use the Auth library](#using-external-identities) to call Google Cloud +resources from Azure. + +#### Accessing resources from an OIDC identity provider + +In order to access Google Cloud resources from an identity provider that supports [OpenID Connect (OIDC)](https://openid.net/connect/), the following requirements are needed: +- A workload identity pool needs to be created. +- An OIDC identity provider needs to be added in the workload identity pool (the Google [organization policy](https://cloud.google.com/iam/docs/manage-workload-identity-pools-providers#restrict) needs to allow federation from the identity provider). +- Permission to impersonate a service account needs to be granted to the external identity. + +Follow the detailed [instructions](https://cloud.google.com/iam/docs/access-resources-oidc) on how +to configure workload identity federation from an OIDC identity provider. + +After configuring the OIDC provider to impersonate a service account, a credential configuration +file needs to be generated. Unlike service account credential files, the generated credential +configuration file contains non-sensitive metadata to instruct the library on how to +retrieve external subject tokens and exchange them for service account access tokens. +The configuration file can be generated by using the [gcloud CLI](https://cloud.google.com/sdk/). + +For OIDC providers, the Auth library can retrieve OIDC tokens either from a local file location +(file-sourced credentials) or from a local server (URL-sourced credentials). + +**File-sourced credentials** +For file-sourced credentials, a background process needs to be continuously refreshing the file +location with a new OIDC token prior to expiration. For tokens with one hour lifetimes, the token +needs to be updated in the file every hour. The token can be stored directly as plain text or in +JSON format. + +To generate a file-sourced OIDC configuration, run the following command: + +```bash +# Generate an OIDC configuration file for file-sourced credentials. +gcloud iam workload-identity-pools create-cred-config \ + projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$POOL_ID/providers/$OIDC_PROVIDER_ID \ + --service-account $SERVICE_ACCOUNT_EMAIL \ + --credential-source-file $PATH_TO_OIDC_ID_TOKEN \ + # Optional arguments for file types. Default is "text": + # --credential-source-type "json" \ + # Optional argument for the field that contains the OIDC credential. + # This is required for json. + # --credential-source-field-name "id_token" \ + --output-file /path/to/generated/config.json +``` + +Where the following variables need to be substituted: +- `$PROJECT_NUMBER`: The Google Cloud project number. +- `$POOL_ID`: The workload identity pool ID. +- `$OIDC_PROVIDER_ID`: The OIDC provider ID. +- `$SERVICE_ACCOUNT_EMAIL`: The email of the service account to impersonate. +- `$PATH_TO_OIDC_ID_TOKEN`: The file path used to retrieve the OIDC token. + +This generates the configuration file in the specified output file. + +**URL-sourced credentials** +For URL-sourced credentials, a local server needs to host a GET endpoint to return the OIDC token. +The response can be in plain text or JSON. Additional required request headers can also be +specified. + +To generate a URL-sourced OIDC workload identity configuration, run the following command: + +```bash +# Generate an OIDC configuration file for URL-sourced credentials. +gcloud iam workload-identity-pools create-cred-config \ + projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$POOL_ID/providers/$OIDC_PROVIDER_ID \ + --service-account $SERVICE_ACCOUNT_EMAIL \ + --credential-source-url $URL_TO_GET_OIDC_TOKEN \ + --credential-source-headers $HEADER_KEY=$HEADER_VALUE \ + # Optional arguments for file types. Default is "text": + # --credential-source-type "json" \ + # Optional argument for the field that contains the OIDC credential. + # This is required for json. + # --credential-source-field-name "id_token" \ + --output-file /path/to/generated/config.json +``` + +Where the following variables need to be substituted: +- `$PROJECT_NUMBER`: The Google Cloud project number. +- `$POOL_ID`: The workload identity pool ID. +- `$OIDC_PROVIDER_ID`: The OIDC provider ID. +- `$SERVICE_ACCOUNT_EMAIL`: The email of the service account to impersonate. +- `$URL_TO_GET_OIDC_TOKEN`: The URL of the local server endpoint to call to retrieve the OIDC token. +- `$HEADER_KEY` and `$HEADER_VALUE`: The additional header key/value pairs to pass along the GET +request to `$URL_TO_GET_OIDC_TOKEN`, e.g. `Metadata-Flavor=Google`. + +You can now [use the Auth library](#using-external-identities) to call Google Cloud +resources from an OIDC provider. + +#### Using External Identities + +External identities (AWS, Azure, and OIDC-based providers) can be used with +`Application Default Credentials`. In order to use external identities with Application Default +Credentials, you need to generate the JSON credentials configuration file for your external identity +as described above. Once generated, store the path to this file in the +`GOOGLE_APPLICATION_CREDENTIALS` environment variable. + +```bash +export GOOGLE_APPLICATION_CREDENTIALS=/path/to/config.json +``` + +The library can now choose the right type of client and initialize credentials from the context +provided in the configuration file. + +```java +GoogleCredentials googleCredentials = GoogleCredentials.getApplicationDefault(); + +String projectId = "your-project-id"; +String url = "https://storage.googleapis.com/storage/v1/b?project=" + projectId; + +HttpCredentialsAdapter credentialsAdapter = new HttpCredentialsAdapter(googleCredentials); +HttpRequestFactory requestFactory = new NetHttpTransport().createRequestFactory(credentialsAdapter); +HttpRequest request = requestFactory.buildGetRequest(new GenericUrl(url)); + +JsonObjectParser parser = new JsonObjectParser(GsonFactory.getDefaultInstance()); +request.setParser(parser); + +HttpResponse response = request.execute(); +System.out.println(response.parseAsString()); +``` + +You can also explicitly initialize external account clients using the generated configuration file. + +```java +ExternalAccountCredentials credentials = + ExternalAccountCredentials.fromStream(new FileInputStream("/path/to/credentials.json")); +``` + ## Configuring a Proxy For HTTP clients, a basic proxy can be configured by using `http.proxyHost` and related system properties as documented From b8dde1e43f86a0a00741790c12d73f6cbda6251d Mon Sep 17 00:00:00 2001 From: Leo <39062083+lsirac@users.noreply.github.com> Date: Thu, 18 Feb 2021 12:13:33 -0800 Subject: [PATCH 07/10] feat: add workload identity federation support (#547) Adds new credential types for File/URL based external credentials. Adds utilities for STS token exchange See go/guac-3pi-java. Co-authored-by: Jeff Ching --- .../com/google/auth/oauth2/ActingParty.java | 56 ++ .../google/auth/oauth2/AwsCredentials.java | 349 +++++++++++ .../java/com/google/auth/oauth2/AwsDates.java | 99 ++++ .../auth/oauth2/AwsRequestSignature.java | 191 ++++++ .../google/auth/oauth2/AwsRequestSigner.java | 336 +++++++++++ .../auth/oauth2/AwsSecurityCredentials.java | 65 +++ .../oauth2/CredentialFormatException.java | 41 ++ .../oauth2/ExternalAccountCredentials.java | 456 +++++++++++++++ .../google/auth/oauth2/GoogleCredentials.java | 5 +- .../auth/oauth2/IdentityPoolCredentials.java | 318 ++++++++++ .../google/auth/oauth2/OAuthException.java | 81 +++ .../google/auth/oauth2/StsRequestHandler.java | 226 ++++++++ .../auth/oauth2/StsTokenExchangeRequest.java | 184 ++++++ .../auth/oauth2/StsTokenExchangeResponse.java | 139 +++++ .../javatests/com/google/auth/TestUtils.java | 41 +- .../auth/oauth2/AwsCredentialsTest.java | 539 +++++++++++++++++ .../auth/oauth2/AwsRequestSignerTest.java | 544 ++++++++++++++++++ .../ExternalAccountCredentialsTest.java | 358 ++++++++++++ .../auth/oauth2/GoogleCredentialsTest.java | 40 ++ .../oauth2/IdentityPoolCredentialsTest.java | 488 ++++++++++++++++ ...ckExternalAccountCredentialsTransport.java | 263 +++++++++ .../auth/oauth2/OAuthExceptionTest.java | 87 +++ .../auth/oauth2/StsRequestHandlerTest.java | 251 ++++++++ .../aws_security_credentials.json | 9 + 24 files changed, 5159 insertions(+), 7 deletions(-) create mode 100644 oauth2_http/java/com/google/auth/oauth2/ActingParty.java create mode 100644 oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java create mode 100644 oauth2_http/java/com/google/auth/oauth2/AwsDates.java create mode 100644 oauth2_http/java/com/google/auth/oauth2/AwsRequestSignature.java create mode 100644 oauth2_http/java/com/google/auth/oauth2/AwsRequestSigner.java create mode 100644 oauth2_http/java/com/google/auth/oauth2/AwsSecurityCredentials.java create mode 100644 oauth2_http/java/com/google/auth/oauth2/CredentialFormatException.java create mode 100644 oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java create mode 100644 oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java create mode 100644 oauth2_http/java/com/google/auth/oauth2/OAuthException.java create mode 100644 oauth2_http/java/com/google/auth/oauth2/StsRequestHandler.java create mode 100644 oauth2_http/java/com/google/auth/oauth2/StsTokenExchangeRequest.java create mode 100644 oauth2_http/java/com/google/auth/oauth2/StsTokenExchangeResponse.java create mode 100644 oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java create mode 100644 oauth2_http/javatests/com/google/auth/oauth2/AwsRequestSignerTest.java create mode 100644 oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java create mode 100644 oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java create mode 100644 oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java create mode 100644 oauth2_http/javatests/com/google/auth/oauth2/OAuthExceptionTest.java create mode 100644 oauth2_http/javatests/com/google/auth/oauth2/StsRequestHandlerTest.java create mode 100644 oauth2_http/testresources/aws_security_credentials.json diff --git a/oauth2_http/java/com/google/auth/oauth2/ActingParty.java b/oauth2_http/java/com/google/auth/oauth2/ActingParty.java new file mode 100644 index 000000000..ad1d452fc --- /dev/null +++ b/oauth2_http/java/com/google/auth/oauth2/ActingParty.java @@ -0,0 +1,56 @@ +/* + * Copyright 2021 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import static com.google.common.base.Preconditions.checkNotNull; + +/** + * The acting party as defined in OAuth 2.0 Token + * Exchange. + */ +final class ActingParty { + private final String actorToken; + private final String actorTokenType; + + ActingParty(String actorToken, String actorTokenType) { + this.actorToken = checkNotNull(actorToken); + this.actorTokenType = checkNotNull(actorTokenType); + } + + String getActorToken() { + return actorToken; + } + + String getActorTokenType() { + return actorTokenType; + } +} diff --git a/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java b/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java new file mode 100644 index 000000000..b12d4e1cf --- /dev/null +++ b/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java @@ -0,0 +1,349 @@ +/* + * Copyright 2021 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import com.google.api.client.http.GenericUrl; +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpRequestFactory; +import com.google.api.client.http.HttpResponse; +import com.google.api.client.json.GenericJson; +import com.google.api.client.json.JsonParser; +import com.google.auth.http.HttpTransportFactory; +import com.google.common.annotations.VisibleForTesting; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.annotation.Nullable; + +/** + * AWS credentials representing a third-party identity for calling Google APIs. + * + *

By default, attempts to exchange the external credential for a GCP access token. + */ +public class AwsCredentials extends ExternalAccountCredentials { + + /** + * The AWS credential source. Stores data required to retrieve the AWS credential from the AWS + * metadata server. + */ + static class AwsCredentialSource extends CredentialSource { + + private final String regionUrl; + private final String url; + private final String regionalCredentialVerificationUrl; + + /** + * The source of the AWS credential. The credential source map must contain the + * `regional_cred_verification_url` field. + * + *

The `regional_cred_verification_url` is the regional GetCallerIdentity action URL, used to + * determine the account ID and its roles. + * + *

The `environment_id` is the environment identifier, in the format “aws${version}”. This + * indicates whether breaking changes were introduced to the underlying AWS implementation. + * + *

The `region_url` identifies the targeted region. Optional. + * + *

The `url` locates the metadata server used to retrieve the AWS credentials. Optional. + */ + AwsCredentialSource(Map credentialSourceMap) { + super(credentialSourceMap); + if (!credentialSourceMap.containsKey("regional_cred_verification_url")) { + throw new IllegalArgumentException( + "A regional_cred_verification_url representing the" + + " GetCallerIdentity action URL must be specified."); + } + + String environmentId = (String) credentialSourceMap.get("environment_id"); + + // Environment version is prefixed by "aws". e.g. "aws1". + Matcher matcher = Pattern.compile("(aws)([\\d]+)").matcher(environmentId); + if (!matcher.matches()) { + throw new IllegalArgumentException("Invalid AWS environment ID."); + } + + int environmentVersion = Integer.parseInt(matcher.group(2)); + if (environmentVersion != 1) { + throw new IllegalArgumentException( + String.format( + "AWS version %s is not supported in the current build.", environmentVersion)); + } + + this.regionUrl = (String) credentialSourceMap.get("region_url"); + this.url = (String) credentialSourceMap.get("url"); + this.regionalCredentialVerificationUrl = + (String) credentialSourceMap.get("regional_cred_verification_url"); + } + } + + private final AwsCredentialSource awsCredentialSource; + + /** + * Internal constructor. See {@link + * ExternalAccountCredentials#ExternalAccountCredentials(HttpTransportFactory, String, String, + * String, CredentialSource, String, String, String, String, String, Collection)} + */ + AwsCredentials( + HttpTransportFactory transportFactory, + String audience, + String subjectTokenType, + String tokenUrl, + AwsCredentialSource credentialSource, + @Nullable String tokenInfoUrl, + @Nullable String serviceAccountImpersonationUrl, + @Nullable String quotaProjectId, + @Nullable String clientId, + @Nullable String clientSecret, + @Nullable Collection scopes) { + super( + transportFactory, + audience, + subjectTokenType, + tokenUrl, + credentialSource, + tokenInfoUrl, + serviceAccountImpersonationUrl, + quotaProjectId, + clientId, + clientSecret, + scopes); + this.awsCredentialSource = credentialSource; + } + + @Override + public AccessToken refreshAccessToken() throws IOException { + StsTokenExchangeRequest.Builder stsTokenExchangeRequest = + StsTokenExchangeRequest.newBuilder(retrieveSubjectToken(), getSubjectTokenType()) + .setAudience(getAudience()); + + // Add scopes, if possible. + Collection scopes = getScopes(); + if (scopes != null && !scopes.isEmpty()) { + stsTokenExchangeRequest.setScopes(new ArrayList<>(scopes)); + } + + return exchangeExternalCredentialForAccessToken(stsTokenExchangeRequest.build()); + } + + @Override + public String retrieveSubjectToken() throws IOException { + // The targeted region is required to generate the signed request. The regional + // endpoint must also be used. + String region = getAwsRegion(); + + AwsSecurityCredentials credentials = getAwsSecurityCredentials(); + + // Generate the signed request to the AWS STS GetCallerIdentity API. + Map headers = new HashMap<>(); + headers.put("x-goog-cloud-target-resource", getAudience()); + + AwsRequestSigner signer = + AwsRequestSigner.newBuilder( + credentials, + "POST", + awsCredentialSource.regionalCredentialVerificationUrl.replace("{region}", region), + region) + .setAdditionalHeaders(headers) + .build(); + + AwsRequestSignature awsRequestSignature = signer.sign(); + return buildSubjectToken(awsRequestSignature); + } + + /** Clones the AwsCredentials with the specified scopes. */ + @Override + public GoogleCredentials createScoped(Collection newScopes) { + return new AwsCredentials( + transportFactory, + getAudience(), + getSubjectTokenType(), + getTokenUrl(), + awsCredentialSource, + getTokenInfoUrl(), + getServiceAccountImpersonationUrl(), + getQuotaProjectId(), + getClientId(), + getClientSecret(), + newScopes); + } + + private String retrieveResource(String url, String resourceName) throws IOException { + try { + HttpRequestFactory requestFactory = transportFactory.create().createRequestFactory(); + HttpRequest request = requestFactory.buildGetRequest(new GenericUrl(url)); + HttpResponse response = request.execute(); + return response.parseAsString(); + } catch (IOException e) { + throw new IOException(String.format("Failed to retrieve AWS %s.", resourceName), e); + } + } + + private String buildSubjectToken(AwsRequestSignature signature) + throws UnsupportedEncodingException { + Map canonicalHeaders = signature.getCanonicalHeaders(); + List headerList = new ArrayList<>(); + for (String headerName : canonicalHeaders.keySet()) { + headerList.add(formatTokenHeaderForSts(headerName, canonicalHeaders.get(headerName))); + } + + headerList.add(formatTokenHeaderForSts("Authorization", signature.getAuthorizationHeader())); + + // The canonical resource name of the workload identity pool provider. + headerList.add(formatTokenHeaderForSts("x-goog-cloud-target-resource", getAudience())); + + GenericJson token = new GenericJson(); + token.setFactory(OAuth2Utils.JSON_FACTORY); + + token.put("headers", headerList); + token.put("method", signature.getHttpMethod()); + token.put( + "url", + awsCredentialSource.regionalCredentialVerificationUrl.replace( + "{region}", signature.getRegion())); + return URLEncoder.encode(token.toString(), "UTF-8"); + } + + private String getAwsRegion() throws IOException { + // For AWS Lambda, the region is retrieved through the AWS_REGION environment variable. + String region = getEnv("AWS_REGION"); + if (region != null) { + return region; + } + + if (awsCredentialSource.regionUrl == null || awsCredentialSource.regionUrl.isEmpty()) { + throw new IOException( + "Unable to determine the AWS region. The credential source does not contain the region URL."); + } + + region = retrieveResource(awsCredentialSource.regionUrl, "region"); + + // There is an extra appended character that must be removed. If `us-east-1b` is returned, + // we want `us-east-1`. + return region.substring(0, region.length() - 1); + } + + @VisibleForTesting + AwsSecurityCredentials getAwsSecurityCredentials() throws IOException { + // Check environment variables for credentials first. + String accessKeyId = getEnv("AWS_ACCESS_KEY_ID"); + String secretAccessKey = getEnv("AWS_SECRET_ACCESS_KEY"); + String token = getEnv("Token"); + if (accessKeyId != null && secretAccessKey != null) { + return new AwsSecurityCredentials(accessKeyId, secretAccessKey, token); + } + + // Credentials not retrievable from environment variables - call metadata server. + // Retrieve the IAM role that is attached to the VM. This is required to retrieve the AWS + // security credentials. + if (awsCredentialSource.url == null || awsCredentialSource.url.isEmpty()) { + throw new IOException( + "Unable to determine the AWS IAM role name. The credential source does not contain the" + + " url field."); + } + String roleName = retrieveResource(awsCredentialSource.url, "IAM role"); + + // Retrieve the AWS security credentials by calling the endpoint specified by the credential + // source. + String awsCredentials = + retrieveResource(awsCredentialSource.url + "/" + roleName, "credentials"); + + JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser(awsCredentials); + GenericJson genericJson = parser.parseAndClose(GenericJson.class); + + accessKeyId = (String) genericJson.get("AccessKeyId"); + secretAccessKey = (String) genericJson.get("SecretAccessKey"); + token = (String) genericJson.get("Token"); + + // These credentials last for a few hours - we may consider caching these in the + // future. + return new AwsSecurityCredentials(accessKeyId, secretAccessKey, token); + } + + @VisibleForTesting + String getEnv(String name) { + return System.getenv(name); + } + + private static GenericJson formatTokenHeaderForSts(String key, String value) { + // The GCP STS endpoint expects the headers to be formatted as: + // [ + // {key: 'x-amz-date', value: '...'}, + // {key: 'Authorization', value: '...'}, + // ... + // ] + GenericJson header = new GenericJson(); + header.setFactory(OAuth2Utils.JSON_FACTORY); + header.put("key", key); + header.put("value", value); + return header; + } + + public static AwsCredentials.Builder newBuilder() { + return new AwsCredentials.Builder(); + } + + public static AwsCredentials.Builder newBuilder(AwsCredentials awsCredentials) { + return new AwsCredentials.Builder(awsCredentials); + } + + public static class Builder extends ExternalAccountCredentials.Builder { + + Builder() {} + + Builder(AwsCredentials credentials) { + super(credentials); + } + + @Override + public AwsCredentials build() { + return new AwsCredentials( + transportFactory, + audience, + subjectTokenType, + tokenUrl, + (AwsCredentialSource) credentialSource, + tokenInfoUrl, + serviceAccountImpersonationUrl, + quotaProjectId, + clientId, + clientSecret, + scopes); + } + } +} diff --git a/oauth2_http/java/com/google/auth/oauth2/AwsDates.java b/oauth2_http/java/com/google/auth/oauth2/AwsDates.java new file mode 100644 index 000000000..abf81add9 --- /dev/null +++ b/oauth2_http/java/com/google/auth/oauth2/AwsDates.java @@ -0,0 +1,99 @@ +/* + * Copyright 2021 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import static com.google.common.base.Preconditions.checkNotNull; + +import java.text.DateFormat; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.TimeZone; + +/** Formats dates required for AWS Signature V4 request signing. */ +final class AwsDates { + private static final String X_AMZ_DATE_FORMAT = "yyyyMMdd'T'HHmmss'Z'"; + private static final String HTTP_DATE_FORMAT = "E, dd MMM yyyy HH:mm:ss z"; + + private final String xAmzDate; + private final String originalDate; + + private AwsDates(String amzDate) { + this.xAmzDate = checkNotNull(amzDate); + this.originalDate = amzDate; + } + + private AwsDates(String xAmzDate, String originalDate) { + this.xAmzDate = checkNotNull(xAmzDate); + this.originalDate = checkNotNull(originalDate); + } + + /** + * Returns the original date. This can either be the x-amz-date or a specified date in the format + * of E, dd MMM yyyy HH:mm:ss z. + */ + String getOriginalDate() { + return originalDate; + } + + /** Returns the x-amz-date in yyyyMMdd'T'HHmmss'Z' format. */ + String getXAmzDate() { + return xAmzDate; + } + + /** Returns the x-amz-date in YYYYMMDD format. */ + String getFormattedDate() { + return xAmzDate.substring(0, 8); + } + + static AwsDates fromXAmzDate(String xAmzDate) throws ParseException { + // Validate format + new SimpleDateFormat(AwsDates.X_AMZ_DATE_FORMAT).parse(xAmzDate); + return new AwsDates(xAmzDate); + } + + static AwsDates fromDateHeader(String date) throws ParseException { + DateFormat dateFormat = new SimpleDateFormat(X_AMZ_DATE_FORMAT); + dateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); + + Date inputDate = new SimpleDateFormat(HTTP_DATE_FORMAT).parse(date); + String xAmzDate = dateFormat.format(inputDate); + return new AwsDates(xAmzDate, date); + } + + static AwsDates generateXAmzDate() { + DateFormat dateFormat = new SimpleDateFormat(X_AMZ_DATE_FORMAT); + dateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); + String xAmzDate = dateFormat.format(new Date(System.currentTimeMillis())); + return new AwsDates(xAmzDate); + } +} diff --git a/oauth2_http/java/com/google/auth/oauth2/AwsRequestSignature.java b/oauth2_http/java/com/google/auth/oauth2/AwsRequestSignature.java new file mode 100644 index 000000000..463b84676 --- /dev/null +++ b/oauth2_http/java/com/google/auth/oauth2/AwsRequestSignature.java @@ -0,0 +1,191 @@ +/* + * Copyright 2021 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import java.util.HashMap; +import java.util.Map; + +/** + * Stores the AWS API request signature based on the AWS Signature Version 4 signing process, and + * the parameters used in the signing process. + */ +class AwsRequestSignature { + + private AwsSecurityCredentials awsSecurityCredentials; + private Map canonicalHeaders; + + private String signature; + private String credentialScope; + private String url; + private String httpMethod; + private String date; + private String region; + private String authorizationHeader; + + private AwsRequestSignature( + AwsSecurityCredentials awsSecurityCredentials, + Map canonicalHeaders, + String signature, + String credentialScope, + String url, + String httpMethod, + String date, + String region, + String authorizationHeader) { + this.awsSecurityCredentials = awsSecurityCredentials; + this.canonicalHeaders = canonicalHeaders; + this.signature = signature; + this.credentialScope = credentialScope; + this.url = url; + this.httpMethod = httpMethod; + this.date = date; + this.region = region; + this.authorizationHeader = authorizationHeader; + } + + /** Returns the request signature based on the AWS Signature Version 4 signing process. */ + String getSignature() { + return signature; + } + + /** Returns the credential scope. e.g. 20150830/us-east-1/iam/aws4_request */ + String getCredentialScope() { + return credentialScope; + } + + /** Returns the AWS security credentials. */ + AwsSecurityCredentials getSecurityCredentials() { + return awsSecurityCredentials; + } + + /** Returns the request URL. */ + String getUrl() { + return url; + } + + /** Returns the HTTP request method. */ + String getHttpMethod() { + return httpMethod; + } + + /** Returns the HTTP request canonical headers. */ + Map getCanonicalHeaders() { + return new HashMap<>(canonicalHeaders); + } + + /** Returns the request date. */ + String getDate() { + return date; + } + + /** Returns the targeted region. */ + String getRegion() { + return region; + } + + /** Returns the authorization header. */ + String getAuthorizationHeader() { + return authorizationHeader; + } + + static class Builder { + + private AwsSecurityCredentials awsSecurityCredentials; + private Map canonicalHeaders; + + private String signature; + private String credentialScope; + private String url; + private String httpMethod; + private String date; + private String region; + private String authorizationHeader; + + Builder setSignature(String signature) { + this.signature = signature; + return this; + } + + Builder setCredentialScope(String credentialScope) { + this.credentialScope = credentialScope; + return this; + } + + Builder setSecurityCredentials(AwsSecurityCredentials awsSecurityCredentials) { + this.awsSecurityCredentials = awsSecurityCredentials; + return this; + } + + Builder setUrl(String url) { + this.url = url; + return this; + } + + Builder setHttpMethod(String httpMethod) { + this.httpMethod = httpMethod; + return this; + } + + Builder setCanonicalHeaders(Map canonicalHeaders) { + this.canonicalHeaders = new HashMap<>(canonicalHeaders); + return this; + } + + Builder setDate(String date) { + this.date = date; + return this; + } + + Builder setRegion(String region) { + this.region = region; + return this; + } + + Builder setAuthorizationHeader(String authorizationHeader) { + this.authorizationHeader = authorizationHeader; + return this; + } + + AwsRequestSignature build() { + return new AwsRequestSignature( + awsSecurityCredentials, + canonicalHeaders, + signature, + credentialScope, + url, + httpMethod, + date, + region, + authorizationHeader); + } + } +} diff --git a/oauth2_http/java/com/google/auth/oauth2/AwsRequestSigner.java b/oauth2_http/java/com/google/auth/oauth2/AwsRequestSigner.java new file mode 100644 index 000000000..70d930bbe --- /dev/null +++ b/oauth2_http/java/com/google/auth/oauth2/AwsRequestSigner.java @@ -0,0 +1,336 @@ +/* + * Copyright 2021 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import static com.google.common.base.Preconditions.checkNotNull; +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.google.auth.ServiceAccountSigner.SigningException; +import com.google.common.base.Joiner; +import com.google.common.base.Splitter; +import com.google.common.io.BaseEncoding; +import java.net.URI; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.text.ParseException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import javax.annotation.Nullable; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * Internal utility that signs AWS API requests based on the AWS Signature Version 4 signing + * process. + * + * @see AWS + * Signature V4 + */ +class AwsRequestSigner { + + // AWS Signature Version 4 signing algorithm identifier. + private static final String HASHING_ALGORITHM = "AWS4-HMAC-SHA256"; + + // The termination string for the AWS credential scope value as defined in + // https://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html + private static final String AWS_REQUEST_TYPE = "aws4_request"; + + private final AwsSecurityCredentials awsSecurityCredentials; + private final Map additionalHeaders; + private final String httpMethod; + private final String region; + private final String requestPayload; + private final URI uri; + private final AwsDates dates; + + /** + * Internal constructor. + * + * @param awsSecurityCredentials AWS security credentials + * @param httpMethod the HTTP request method + * @param url the request URL + * @param region the targeted region + * @param requestPayload the request payload + * @param additionalHeaders a map of additional HTTP headers to be included with the signed + * request + */ + private AwsRequestSigner( + AwsSecurityCredentials awsSecurityCredentials, + String httpMethod, + String url, + String region, + @Nullable String requestPayload, + @Nullable Map additionalHeaders, + @Nullable AwsDates awsDates) { + this.awsSecurityCredentials = checkNotNull(awsSecurityCredentials); + this.httpMethod = checkNotNull(httpMethod); + this.uri = URI.create(url).normalize(); + this.region = checkNotNull(region); + this.requestPayload = requestPayload == null ? "" : requestPayload; + this.additionalHeaders = + (additionalHeaders != null) + ? new HashMap<>(additionalHeaders) + : new HashMap(); + this.dates = awsDates == null ? AwsDates.generateXAmzDate() : awsDates; + } + + /** + * Signs the specified AWS API request. + * + * @return the {@link AwsRequestSignature} + */ + AwsRequestSignature sign() { + // Retrieve the service name. For example: iam.amazonaws.com host => iam service. + String serviceName = Splitter.on(".").split(uri.getHost()).iterator().next(); + + Map canonicalHeaders = getCanonicalHeaders(dates.getOriginalDate()); + // Headers must be sorted. + List sortedHeaderNames = new ArrayList<>(); + for (String headerName : canonicalHeaders.keySet()) { + sortedHeaderNames.add(headerName.toLowerCase(Locale.US)); + } + Collections.sort(sortedHeaderNames); + + String canonicalRequestHash = createCanonicalRequestHash(canonicalHeaders, sortedHeaderNames); + String credentialScope = + dates.getFormattedDate() + "/" + region + "/" + serviceName + "/" + AWS_REQUEST_TYPE; + String stringToSign = + createStringToSign(canonicalRequestHash, dates.getXAmzDate(), credentialScope); + String signature = + calculateAwsV4Signature( + serviceName, + awsSecurityCredentials.getSecretAccessKey(), + dates.getFormattedDate(), + region, + stringToSign); + + String authorizationHeader = + generateAuthorizationHeader( + sortedHeaderNames, awsSecurityCredentials.getAccessKeyId(), credentialScope, signature); + + return new AwsRequestSignature.Builder() + .setSignature(signature) + .setCanonicalHeaders(canonicalHeaders) + .setHttpMethod(httpMethod) + .setSecurityCredentials(awsSecurityCredentials) + .setCredentialScope(credentialScope) + .setUrl(uri.toString()) + .setDate(dates.getOriginalDate()) + .setRegion(region) + .setAuthorizationHeader(authorizationHeader) + .build(); + } + + /** Task 1: Create a canonical request for Signature Version 4. */ + private String createCanonicalRequestHash( + Map headers, List sortedHeaderNames) { + // Append the HTTP request method. + StringBuilder canonicalRequest = new StringBuilder(httpMethod).append("\n"); + + // Append the path. + String urlPath = uri.getRawPath().isEmpty() ? "/" : uri.getRawPath(); + canonicalRequest.append(urlPath).append("\n"); + + // Append the canonical query string. + String actionQueryString = uri.getRawQuery() != null ? uri.getRawQuery() : ""; + canonicalRequest.append(actionQueryString).append("\n"); + + // Append the canonical headers. + StringBuilder canonicalHeaders = new StringBuilder(); + for (String headerName : sortedHeaderNames) { + canonicalHeaders.append(headerName).append(":").append(headers.get(headerName)).append("\n"); + } + canonicalRequest.append(canonicalHeaders).append("\n"); + + // Append the signed headers. + canonicalRequest.append(Joiner.on(';').join(sortedHeaderNames)).append("\n"); + + // Append the hashed request payload. + canonicalRequest.append(getHexEncodedSha256Hash(requestPayload.getBytes(UTF_8))); + + // Return the hashed canonical request. + return getHexEncodedSha256Hash(canonicalRequest.toString().getBytes(UTF_8)); + } + + /** Task 2: Create a string to sign for Signature Version 4. */ + private String createStringToSign( + String canonicalRequestHash, String xAmzDate, String credentialScope) { + return HASHING_ALGORITHM + + "\n" + + xAmzDate + + "\n" + + credentialScope + + "\n" + + canonicalRequestHash; + } + + /** + * Task 3: Calculate the signature for AWS Signature Version 4. + * + * @param date the date used in the hashing process in YYYYMMDD format + */ + private String calculateAwsV4Signature( + String serviceName, String secret, String date, String region, String stringToSign) { + byte[] kDate = sign(("AWS4" + secret).getBytes(UTF_8), date.getBytes(UTF_8)); + byte[] kRegion = sign(kDate, region.getBytes(UTF_8)); + byte[] kService = sign(kRegion, serviceName.getBytes(UTF_8)); + byte[] kSigning = sign(kService, AWS_REQUEST_TYPE.getBytes(UTF_8)); + return BaseEncoding.base16().lowerCase().encode(sign(kSigning, stringToSign.getBytes(UTF_8))); + } + + /** Task 4: Format the signature to be added to the HTTP request. */ + private String generateAuthorizationHeader( + List sortedHeaderNames, + String accessKeyId, + String credentialScope, + String signature) { + return String.format( + "%s Credential=%s/%s, SignedHeaders=%s, Signature=%s", + HASHING_ALGORITHM, + accessKeyId, + credentialScope, + Joiner.on(';').join(sortedHeaderNames), + signature); + } + + private Map getCanonicalHeaders(String defaultDate) { + Map headers = new HashMap<>(); + headers.put("host", uri.getHost()); + + // Only add the date if it hasn't been specified through the "date" header. + if (!additionalHeaders.containsKey("date")) { + headers.put("x-amz-date", defaultDate); + } + + if (awsSecurityCredentials.getToken() != null && !awsSecurityCredentials.getToken().isEmpty()) { + headers.put("x-amz-security-token", awsSecurityCredentials.getToken()); + } + + // Add all additional headers. + for (String key : additionalHeaders.keySet()) { + // Header keys need to be lowercase. + headers.put(key.toLowerCase(Locale.US), additionalHeaders.get(key)); + } + return headers; + } + + private static byte[] sign(byte[] key, byte[] value) { + try { + String algorithm = "HmacSHA256"; + Mac mac = Mac.getInstance(algorithm); + mac.init(new SecretKeySpec(key, algorithm)); + return mac.doFinal(value); + } catch (NoSuchAlgorithmException e) { + // Will not occur as HmacSHA256 is supported. We may allow other algorithms in the future. + throw new RuntimeException("HmacSHA256 must be supported by the JVM.", e); + } catch (InvalidKeyException e) { + throw new SigningException("Invalid key used when calculating the AWS V4 Signature", e); + } + } + + private static String getHexEncodedSha256Hash(byte[] bytes) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return BaseEncoding.base16().lowerCase().encode(digest.digest(bytes)); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("Failed to compute SHA-256 hash.", e); + } + } + + static Builder newBuilder( + AwsSecurityCredentials awsSecurityCredentials, String httpMethod, String url, String region) { + return new Builder(awsSecurityCredentials, httpMethod, url, region); + } + + static class Builder { + + private final AwsSecurityCredentials awsSecurityCredentials; + private final String httpMethod; + private final String url; + private final String region; + + @Nullable private String requestPayload; + @Nullable private Map additionalHeaders; + @Nullable private AwsDates dates; + + private Builder( + AwsSecurityCredentials awsSecurityCredentials, + String httpMethod, + String url, + String region) { + this.awsSecurityCredentials = awsSecurityCredentials; + this.httpMethod = httpMethod; + this.url = url; + this.region = region; + } + + Builder setRequestPayload(String requestPayload) { + this.requestPayload = requestPayload; + return this; + } + + Builder setAdditionalHeaders(Map additionalHeaders) { + if (additionalHeaders.containsKey("date") && additionalHeaders.containsKey("x-amz-date")) { + throw new IllegalArgumentException("One of {date, x-amz-date} can be specified, not both."); + } + try { + if (additionalHeaders.containsKey("date")) { + this.dates = AwsDates.fromDateHeader(additionalHeaders.get("date")); + } + if (additionalHeaders.containsKey("x-amz-date")) { + this.dates = AwsDates.fromXAmzDate(additionalHeaders.get("x-amz-date")); + } + } catch (ParseException e) { + throw new IllegalArgumentException("The provided date header value is invalid.", e); + } + + this.additionalHeaders = additionalHeaders; + return this; + } + + AwsRequestSigner build() { + return new AwsRequestSigner( + awsSecurityCredentials, + httpMethod, + url, + region, + requestPayload, + additionalHeaders, + dates); + } + } +} diff --git a/oauth2_http/java/com/google/auth/oauth2/AwsSecurityCredentials.java b/oauth2_http/java/com/google/auth/oauth2/AwsSecurityCredentials.java new file mode 100644 index 000000000..b7865049a --- /dev/null +++ b/oauth2_http/java/com/google/auth/oauth2/AwsSecurityCredentials.java @@ -0,0 +1,65 @@ +/* + * Copyright 2021 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import javax.annotation.Nullable; + +/** + * Defines AWS security credentials. These are either retrieved from the AWS security_credentials + * endpoint or AWS environment variables. + */ +class AwsSecurityCredentials { + + private final String accessKeyId; + private final String secretAccessKey; + + @Nullable private final String token; + + AwsSecurityCredentials(String accessKeyId, String secretAccessKey, @Nullable String token) { + this.accessKeyId = accessKeyId; + this.secretAccessKey = secretAccessKey; + this.token = token; + } + + String getAccessKeyId() { + return accessKeyId; + } + + String getSecretAccessKey() { + return secretAccessKey; + } + + @Nullable + String getToken() { + return token; + } +} diff --git a/oauth2_http/java/com/google/auth/oauth2/CredentialFormatException.java b/oauth2_http/java/com/google/auth/oauth2/CredentialFormatException.java new file mode 100644 index 000000000..4186bc029 --- /dev/null +++ b/oauth2_http/java/com/google/auth/oauth2/CredentialFormatException.java @@ -0,0 +1,41 @@ +/* + * Copyright 2021 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import java.io.IOException; + +/** Indicates that the provided credential does not adhere to the required format. */ +class CredentialFormatException extends IOException { + CredentialFormatException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java new file mode 100644 index 000000000..1373fcc54 --- /dev/null +++ b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java @@ -0,0 +1,456 @@ +/* + * Copyright 2021 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.client.json.GenericJson; +import com.google.api.client.json.JsonObjectParser; +import com.google.auth.http.HttpTransportFactory; +import com.google.auth.oauth2.AwsCredentials.AwsCredentialSource; +import com.google.auth.oauth2.IdentityPoolCredentials.IdentityPoolCredentialSource; +import com.google.common.base.MoreObjects; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import javax.annotation.Nullable; + +/** + * Base external account credentials class. + * + *

Handles initializing external credentials, calls to STS, and service account impersonation. + */ +public abstract class ExternalAccountCredentials extends GoogleCredentials + implements QuotaProjectIdProvider { + + /** Base credential source class. Dictates the retrieval method of the external credential. */ + abstract static class CredentialSource { + + CredentialSource(Map credentialSourceMap) { + checkNotNull(credentialSourceMap); + } + } + + private static final String CLOUD_PLATFORM_SCOPE = + "https://www.googleapis.com/auth/cloud-platform"; + + static final String EXTERNAL_ACCOUNT_FILE_TYPE = "external_account"; + + private final String transportFactoryClassName; + private final String audience; + private final String subjectTokenType; + private final String tokenUrl; + private final CredentialSource credentialSource; + private final Collection scopes; + + @Nullable private final String tokenInfoUrl; + @Nullable private final String serviceAccountImpersonationUrl; + @Nullable private final String quotaProjectId; + @Nullable private final String clientId; + @Nullable private final String clientSecret; + + protected transient HttpTransportFactory transportFactory; + + @Nullable protected final ImpersonatedCredentials impersonatedCredentials; + + /** + * Constructor with minimum identifying information and custom HTTP transport. + * + * @param transportFactory HTTP transport factory, creates the transport used to get access tokens + * @param audience the STS audience which is usually the fully specified resource name of the + * workload/workforce pool provider + * @param subjectTokenType the STS subject token type based on the OAuth 2.0 token exchange spec. + * Indicates the type of the security token in the credential file + * @param tokenUrl the STS token exchange endpoint + * @param tokenInfoUrl the endpoint used to retrieve account related information. Required for + * gCloud session account identification. + * @param credentialSource the external credential source + * @param serviceAccountImpersonationUrl the URL for the service account impersonation request. + * This is only required for workload identity pools when APIs to be accessed have not + * integrated with UberMint. If this is not available, the STS returned GCP access token is + * directly used. May be null. + * @param quotaProjectId the project used for quota and billing purposes. May be null. + * @param clientId client ID of the service account from the console. May be null. + * @param clientSecret client secret of the service account from the console. May be null. + * @param scopes the scopes to request during the authorization grant. May be null. + */ + protected ExternalAccountCredentials( + HttpTransportFactory transportFactory, + String audience, + String subjectTokenType, + String tokenUrl, + CredentialSource credentialSource, + @Nullable String tokenInfoUrl, + @Nullable String serviceAccountImpersonationUrl, + @Nullable String quotaProjectId, + @Nullable String clientId, + @Nullable String clientSecret, + @Nullable Collection scopes) { + this.transportFactory = + MoreObjects.firstNonNull( + transportFactory, + getFromServiceLoader(HttpTransportFactory.class, OAuth2Utils.HTTP_TRANSPORT_FACTORY)); + this.transportFactoryClassName = checkNotNull(this.transportFactory.getClass().getName()); + this.audience = checkNotNull(audience); + this.subjectTokenType = checkNotNull(subjectTokenType); + this.tokenUrl = checkNotNull(tokenUrl); + this.credentialSource = checkNotNull(credentialSource); + this.tokenInfoUrl = tokenInfoUrl; + this.serviceAccountImpersonationUrl = serviceAccountImpersonationUrl; + this.quotaProjectId = quotaProjectId; + this.clientId = clientId; + this.clientSecret = clientSecret; + this.scopes = + (scopes == null || scopes.isEmpty()) ? Arrays.asList(CLOUD_PLATFORM_SCOPE) : scopes; + this.impersonatedCredentials = initializeImpersonatedCredentials(); + } + + private ImpersonatedCredentials initializeImpersonatedCredentials() { + if (serviceAccountImpersonationUrl == null) { + return null; + } + // Create a copy of this instance without service account impersonation. + ExternalAccountCredentials sourceCredentials; + if (this instanceof AwsCredentials) { + sourceCredentials = + AwsCredentials.newBuilder((AwsCredentials) this) + .setServiceAccountImpersonationUrl(null) + .build(); + } else { + sourceCredentials = + IdentityPoolCredentials.newBuilder((IdentityPoolCredentials) this) + .setServiceAccountImpersonationUrl(null) + .build(); + } + + String targetPrincipal = extractTargetPrincipal(serviceAccountImpersonationUrl); + return ImpersonatedCredentials.newBuilder() + .setSourceCredentials(sourceCredentials) + .setHttpTransportFactory(transportFactory) + .setTargetPrincipal(targetPrincipal) + .setScopes(new ArrayList<>(scopes)) + .setLifetime(3600) // 1 hour in seconds + .build(); + } + + @Override + public Map> getRequestMetadata(URI uri) throws IOException { + Map> requestMetadata = super.getRequestMetadata(uri); + return addQuotaProjectIdToRequestMetadata(quotaProjectId, requestMetadata); + } + + /** + * Returns credentials defined by a JSON file stream. + * + *

Returns {@link IdentityPoolCredentials} or {@link AwsCredentials}. + * + * @param credentialsStream the stream with the credential definition + * @return the credential defined by the credentialsStream + * @throws IOException if the credential cannot be created from the stream + */ + public static ExternalAccountCredentials fromStream(InputStream credentialsStream) + throws IOException { + return fromStream(credentialsStream, OAuth2Utils.HTTP_TRANSPORT_FACTORY); + } + + /** + * Returns credentials defined by a JSON file stream. + * + *

Returns a {@link IdentityPoolCredentials} or {@link AwsCredentials}. + * + * @param credentialsStream the stream with the credential definition + * @param transportFactory the HTTP transport factory used to create the transport to get access + * tokens + * @return the credential defined by the credentialsStream + * @throws IOException if the credential cannot be created from the stream + */ + public static ExternalAccountCredentials fromStream( + InputStream credentialsStream, HttpTransportFactory transportFactory) throws IOException { + checkNotNull(credentialsStream); + checkNotNull(transportFactory); + + JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); + GenericJson fileContents = + parser.parseAndClose(credentialsStream, StandardCharsets.UTF_8, GenericJson.class); + try { + return fromJson(fileContents, transportFactory); + } catch (ClassCastException e) { + throw new CredentialFormatException("An invalid input stream was provided.", e); + } + } + + /** + * Returns external account credentials defined by JSON using the format generated by gCloud. + * + * @param json a map from the JSON representing the credentials + * @param transportFactory HTTP transport factory, creates the transport used to get access tokens + * @return the credentials defined by the JSON + */ + static ExternalAccountCredentials fromJson( + Map json, HttpTransportFactory transportFactory) { + checkNotNull(json); + checkNotNull(transportFactory); + + String audience = (String) json.get("audience"); + String subjectTokenType = (String) json.get("subject_token_type"); + String tokenUrl = (String) json.get("token_url"); + String serviceAccountImpersonationUrl = (String) json.get("service_account_impersonation_url"); + + Map credentialSourceMap = (Map) json.get("credential_source"); + + // Optional params. + String tokenInfoUrl = (String) json.get("token_info_url"); + String clientId = (String) json.get("client_id"); + String clientSecret = (String) json.get("client_secret"); + String quotaProjectId = (String) json.get("quota_project_id"); + + if (isAwsCredential(credentialSourceMap)) { + return new AwsCredentials( + transportFactory, + audience, + subjectTokenType, + tokenUrl, + new AwsCredentialSource(credentialSourceMap), + tokenInfoUrl, + serviceAccountImpersonationUrl, + quotaProjectId, + clientId, + clientSecret, + /* scopes= */ null); + } + return new IdentityPoolCredentials( + transportFactory, + audience, + subjectTokenType, + tokenUrl, + new IdentityPoolCredentialSource(credentialSourceMap), + tokenInfoUrl, + serviceAccountImpersonationUrl, + quotaProjectId, + clientId, + clientSecret, + /* scopes= */ null); + } + + private static boolean isAwsCredential(Map credentialSource) { + return credentialSource.containsKey("environment_id") + && ((String) credentialSource.get("environment_id")).startsWith("aws"); + } + + /** + * Exchanges the external credential for a GCP access token. + * + * @param stsTokenExchangeRequest the STS token exchange request + * @return the access token returned by STS + * @throws OAuthException if the call to STS fails + */ + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest) throws IOException { + // Handle service account impersonation if necessary. + if (impersonatedCredentials != null) { + return impersonatedCredentials.refreshAccessToken(); + } + + StsRequestHandler requestHandler = + StsRequestHandler.newBuilder( + tokenUrl, stsTokenExchangeRequest, transportFactory.create().createRequestFactory()) + .build(); + + StsTokenExchangeResponse response = requestHandler.exchangeToken(); + return response.getAccessToken(); + } + + private static String extractTargetPrincipal(String serviceAccountImpersonationUrl) { + // Extract the target principal + int startIndex = serviceAccountImpersonationUrl.lastIndexOf('/'); + int endIndex = serviceAccountImpersonationUrl.indexOf(":generateAccessToken"); + + if (startIndex != -1 && endIndex != -1 && startIndex < endIndex) { + return serviceAccountImpersonationUrl.substring(startIndex + 1, endIndex); + } else { + throw new IllegalArgumentException( + "Unable to determine target principal from service account impersonation URL."); + } + } + + /** + * Retrieves the external subject token to be exchanged for a GCP access token. + * + *

Must be implemented by subclasses as the retrieval method is dependent on the credential + * source. + * + * @return the external subject token + */ + public abstract String retrieveSubjectToken() throws IOException; + + public String getAudience() { + return audience; + } + + public String getSubjectTokenType() { + return subjectTokenType; + } + + public String getTokenUrl() { + return tokenUrl; + } + + public String getTokenInfoUrl() { + return tokenInfoUrl; + } + + public CredentialSource getCredentialSource() { + return credentialSource; + } + + @Nullable + public String getServiceAccountImpersonationUrl() { + return serviceAccountImpersonationUrl; + } + + @Override + @Nullable + public String getQuotaProjectId() { + return quotaProjectId; + } + + @Nullable + public String getClientId() { + return clientId; + } + + @Nullable + public String getClientSecret() { + return clientSecret; + } + + @Nullable + public Collection getScopes() { + return scopes; + } + + /** Base builder for external account credentials. */ + public abstract static class Builder extends GoogleCredentials.Builder { + + protected String audience; + protected String subjectTokenType; + protected String tokenUrl; + protected String tokenInfoUrl; + protected CredentialSource credentialSource; + protected HttpTransportFactory transportFactory; + + @Nullable protected String serviceAccountImpersonationUrl; + @Nullable protected String quotaProjectId; + @Nullable protected String clientId; + @Nullable protected String clientSecret; + @Nullable protected Collection scopes; + + protected Builder() {} + + protected Builder(ExternalAccountCredentials credentials) { + this.transportFactory = credentials.transportFactory; + this.audience = credentials.audience; + this.subjectTokenType = credentials.subjectTokenType; + this.tokenUrl = credentials.tokenUrl; + this.tokenInfoUrl = credentials.tokenInfoUrl; + this.serviceAccountImpersonationUrl = credentials.serviceAccountImpersonationUrl; + this.credentialSource = credentials.credentialSource; + this.quotaProjectId = credentials.quotaProjectId; + this.clientId = credentials.clientId; + this.clientSecret = credentials.clientSecret; + this.scopes = credentials.scopes; + } + + public Builder setAudience(String audience) { + this.audience = audience; + return this; + } + + public Builder setSubjectTokenType(String subjectTokenType) { + this.subjectTokenType = subjectTokenType; + return this; + } + + public Builder setTokenUrl(String tokenUrl) { + this.tokenUrl = tokenUrl; + return this; + } + + public Builder setTokenInfoUrl(String tokenInfoUrl) { + this.tokenInfoUrl = tokenInfoUrl; + return this; + } + + public Builder setServiceAccountImpersonationUrl(String serviceAccountImpersonationUrl) { + this.serviceAccountImpersonationUrl = serviceAccountImpersonationUrl; + return this; + } + + public Builder setCredentialSource(CredentialSource credentialSource) { + this.credentialSource = credentialSource; + return this; + } + + public Builder setScopes(Collection scopes) { + this.scopes = scopes; + return this; + } + + public Builder setQuotaProjectId(String quotaProjectId) { + this.quotaProjectId = quotaProjectId; + return this; + } + + public Builder setClientId(String clientId) { + this.clientId = clientId; + return this; + } + + public Builder setClientSecret(String clientSecret) { + this.clientSecret = clientSecret; + return this; + } + + public Builder setHttpTransportFactory(HttpTransportFactory transportFactory) { + this.transportFactory = transportFactory; + return this; + } + + public abstract ExternalAccountCredentials build(); + } +} diff --git a/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java b/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java index c9ea810fb..3e61e5d60 100644 --- a/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java @@ -50,8 +50,8 @@ public class GoogleCredentials extends OAuth2Credentials { private static final long serialVersionUID = -1522852442442473691L; - static final String QUOTA_PROJECT_ID_HEADER_KEY = "x-goog-user-project"; + static final String QUOTA_PROJECT_ID_HEADER_KEY = "x-goog-user-project"; static final String USER_FILE_TYPE = "authorized_user"; static final String SERVICE_ACCOUNT_FILE_TYPE = "service_account"; @@ -166,6 +166,9 @@ public static GoogleCredentials fromStream( if (SERVICE_ACCOUNT_FILE_TYPE.equals(fileType)) { return ServiceAccountCredentials.fromJson(fileContents, transportFactory); } + if (ExternalAccountCredentials.EXTERNAL_ACCOUNT_FILE_TYPE.equals(fileType)) { + return ExternalAccountCredentials.fromJson(fileContents, transportFactory); + } throw new IOException( String.format( "Error reading credentials from stream, 'type' value '%s' not recognized." diff --git a/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java b/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java new file mode 100644 index 000000000..a82e3a638 --- /dev/null +++ b/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java @@ -0,0 +1,318 @@ +/* + * Copyright 2021 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import com.google.api.client.http.GenericUrl; +import com.google.api.client.http.HttpHeaders; +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpResponse; +import com.google.api.client.json.GenericJson; +import com.google.api.client.json.JsonObjectParser; +import com.google.auth.http.HttpTransportFactory; +import com.google.auth.oauth2.IdentityPoolCredentials.IdentityPoolCredentialSource.CredentialFormatType; +import com.google.common.io.CharStreams; +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import javax.annotation.Nullable; + +/** + * Url-sourced and file-sourced external account credentials. + * + *

By default, attempts to exchange the external credential for a GCP access token. + */ +public class IdentityPoolCredentials extends ExternalAccountCredentials { + + /** + * The IdentityPool credential source. Dictates the retrieval method of the external credential, + * which can either be through a metadata server or a local file. + */ + static class IdentityPoolCredentialSource extends ExternalAccountCredentials.CredentialSource { + + enum IdentityPoolCredentialSourceType { + FILE, + URL + } + + enum CredentialFormatType { + TEXT, + JSON + } + + private IdentityPoolCredentialSourceType credentialSourceType; + private CredentialFormatType credentialFormatType; + private String credentialLocation; + + @Nullable private String subjectTokenFieldName; + @Nullable private Map headers; + + /** + * The source of the 3P credential. + * + *

If this is a file based 3P credential, the credentials file can be retrieved using the + * `file` key. + * + *

If this is URL-based 3p credential, the metadata server URL can be retrieved using the + * `url` key. + * + *

The third party credential can be provided in different formats, such as text or JSON. The + * format can be specified using the `format` header, which returns a map with keys `type` and + * `subject_token_field_name`. If the `type` is json, the `subject_token_field_name` must be + * provided. If no format is provided, we expect the token to be in the raw text format. + * + *

Optional headers can be present, and should be keyed by `headers`. + */ + IdentityPoolCredentialSource(Map credentialSourceMap) { + super(credentialSourceMap); + + if (credentialSourceMap.containsKey("file") && credentialSourceMap.containsKey("url")) { + throw new IllegalArgumentException( + "Only one credential source type can be set, either file or url."); + } + + if (credentialSourceMap.containsKey("file")) { + credentialLocation = (String) credentialSourceMap.get("file"); + credentialSourceType = IdentityPoolCredentialSourceType.FILE; + } else if (credentialSourceMap.containsKey("url")) { + credentialLocation = (String) credentialSourceMap.get("url"); + credentialSourceType = IdentityPoolCredentialSourceType.URL; + } else { + throw new IllegalArgumentException( + "Missing credential source file location or URL. At least one must be specified."); + } + + Map headersMap = (Map) credentialSourceMap.get("headers"); + if (headersMap != null && !headersMap.isEmpty()) { + headers = new HashMap<>(); + headers.putAll(headersMap); + } + + // If the format is not provided, we expect the token to be in the raw text format. + credentialFormatType = CredentialFormatType.TEXT; + + Map formatMap = (Map) credentialSourceMap.get("format"); + if (formatMap != null && formatMap.containsKey("type")) { + String type = formatMap.get("type"); + if (!"text".equals(type) && !"json".equals(type)) { + throw new IllegalArgumentException( + String.format("Invalid credential source format type: %s.", type)); + } + credentialFormatType = + type.equals("text") ? CredentialFormatType.TEXT : CredentialFormatType.JSON; + + if (!formatMap.containsKey("subject_token_field_name")) { + throw new IllegalArgumentException( + "When specifying a JSON credential type, the subject_token_field_name must be set."); + } + subjectTokenFieldName = formatMap.get("subject_token_field_name"); + } + } + + private boolean hasHeaders() { + return headers != null && !headers.isEmpty(); + } + } + + private final IdentityPoolCredentialSource identityPoolCredentialSource; + + /** + * Internal constructor. See {@link + * ExternalAccountCredentials#ExternalAccountCredentials(HttpTransportFactory, String, String, + * String, CredentialSource, String, String, String, String, String, Collection)} + */ + IdentityPoolCredentials( + HttpTransportFactory transportFactory, + String audience, + String subjectTokenType, + String tokenUrl, + IdentityPoolCredentialSource credentialSource, + @Nullable String tokenInfoUrl, + @Nullable String serviceAccountImpersonationUrl, + @Nullable String quotaProjectId, + @Nullable String clientId, + @Nullable String clientSecret, + @Nullable Collection scopes) { + super( + transportFactory, + audience, + subjectTokenType, + tokenUrl, + credentialSource, + tokenInfoUrl, + serviceAccountImpersonationUrl, + quotaProjectId, + clientId, + clientSecret, + scopes); + this.identityPoolCredentialSource = credentialSource; + } + + @Override + public AccessToken refreshAccessToken() throws IOException { + String credential = retrieveSubjectToken(); + StsTokenExchangeRequest.Builder stsTokenExchangeRequest = + StsTokenExchangeRequest.newBuilder(credential, getSubjectTokenType()) + .setAudience(getAudience()); + + Collection scopes = getScopes(); + if (scopes != null && !scopes.isEmpty()) { + stsTokenExchangeRequest.setScopes(new ArrayList<>(scopes)); + } + + return exchangeExternalCredentialForAccessToken(stsTokenExchangeRequest.build()); + } + + @Override + public String retrieveSubjectToken() throws IOException { + if (identityPoolCredentialSource.credentialSourceType + == IdentityPoolCredentialSource.IdentityPoolCredentialSourceType.FILE) { + return retrieveSubjectTokenFromCredentialFile(); + } + return getSubjectTokenFromMetadataServer(); + } + + private String retrieveSubjectTokenFromCredentialFile() throws IOException { + String credentialFilePath = identityPoolCredentialSource.credentialLocation; + if (!Files.exists(Paths.get(credentialFilePath), LinkOption.NOFOLLOW_LINKS)) { + throw new IOException( + String.format( + "Invalid credential location. The file at %s does not exist.", credentialFilePath)); + } + try { + return parseToken(new FileInputStream(new File(credentialFilePath))); + } catch (IOException e) { + throw new IOException( + "Error when attempting to read the subject token from the credential file.", e); + } + } + + private String parseToken(InputStream inputStream) throws IOException { + if (identityPoolCredentialSource.credentialFormatType == CredentialFormatType.TEXT) { + BufferedReader reader = + new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8)); + return CharStreams.toString(reader); + } + + JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); + GenericJson fileContents = + parser.parseAndClose(inputStream, StandardCharsets.UTF_8, GenericJson.class); + + if (!fileContents.containsKey(identityPoolCredentialSource.subjectTokenFieldName)) { + throw new IOException("Invalid subject token field name. No subject token was found."); + } + return (String) fileContents.get(identityPoolCredentialSource.subjectTokenFieldName); + } + + private String getSubjectTokenFromMetadataServer() throws IOException { + HttpRequest request = + transportFactory + .create() + .createRequestFactory() + .buildGetRequest(new GenericUrl(identityPoolCredentialSource.credentialLocation)); + request.setParser(new JsonObjectParser(OAuth2Utils.JSON_FACTORY)); + + if (identityPoolCredentialSource.hasHeaders()) { + HttpHeaders headers = new HttpHeaders(); + headers.putAll(identityPoolCredentialSource.headers); + request.setHeaders(headers); + } + + try { + HttpResponse response = request.execute(); + return parseToken(response.getContent()); + } catch (IOException e) { + throw new IOException( + String.format("Error getting subject token from metadata server: %s", e.getMessage()), e); + } + } + + /** Clones the IdentityPoolCredentials with the specified scopes. */ + @Override + public IdentityPoolCredentials createScoped(Collection newScopes) { + return new IdentityPoolCredentials( + transportFactory, + getAudience(), + getSubjectTokenType(), + getTokenUrl(), + identityPoolCredentialSource, + getTokenInfoUrl(), + getServiceAccountImpersonationUrl(), + getQuotaProjectId(), + getClientId(), + getClientSecret(), + newScopes); + } + + public static Builder newBuilder() { + return new Builder(); + } + + public static Builder newBuilder(IdentityPoolCredentials identityPoolCredentials) { + return new Builder(identityPoolCredentials); + } + + public static class Builder extends ExternalAccountCredentials.Builder { + + Builder() {} + + Builder(IdentityPoolCredentials credentials) { + super(credentials); + } + + @Override + public IdentityPoolCredentials build() { + return new IdentityPoolCredentials( + transportFactory, + audience, + subjectTokenType, + tokenUrl, + (IdentityPoolCredentialSource) credentialSource, + tokenInfoUrl, + serviceAccountImpersonationUrl, + quotaProjectId, + clientId, + clientSecret, + scopes); + } + } +} diff --git a/oauth2_http/java/com/google/auth/oauth2/OAuthException.java b/oauth2_http/java/com/google/auth/oauth2/OAuthException.java new file mode 100644 index 000000000..b3f612a04 --- /dev/null +++ b/oauth2_http/java/com/google/auth/oauth2/OAuthException.java @@ -0,0 +1,81 @@ +/* + * Copyright 2021 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import static com.google.common.base.Preconditions.checkNotNull; + +import java.io.IOException; +import javax.annotation.Nullable; + +/** + * Encapsulates the standard OAuth error response. See + * https://tools.ietf.org/html/rfc6749#section-5.2. + */ +class OAuthException extends IOException { + + private final String errorCode; + @Nullable private final String errorDescription; + @Nullable private final String errorUri; + + OAuthException(String errorCode, @Nullable String errorDescription, @Nullable String errorUri) { + this.errorCode = checkNotNull(errorCode); + this.errorDescription = errorDescription; + this.errorUri = errorUri; + } + + @Override + public String getMessage() { + // Fully specified message will have the format Error code %s: %s - %s. + StringBuilder sb = new StringBuilder("Error code " + errorCode); + if (errorDescription != null) { + sb.append(": ").append(errorDescription); + } + if (errorUri != null) { + sb.append(" - ").append(errorUri); + } + return sb.toString(); + } + + String getErrorCode() { + return errorCode; + } + + @Nullable + String getErrorDescription() { + return errorDescription; + } + + @Nullable + String getErrorUri() { + return errorUri; + } +} diff --git a/oauth2_http/java/com/google/auth/oauth2/StsRequestHandler.java b/oauth2_http/java/com/google/auth/oauth2/StsRequestHandler.java new file mode 100644 index 000000000..a6a14fcbf --- /dev/null +++ b/oauth2_http/java/com/google/auth/oauth2/StsRequestHandler.java @@ -0,0 +1,226 @@ +/* + * Copyright 2021 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import com.google.api.client.http.GenericUrl; +import com.google.api.client.http.HttpHeaders; +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpRequestFactory; +import com.google.api.client.http.HttpResponse; +import com.google.api.client.http.HttpResponseException; +import com.google.api.client.http.UrlEncodedContent; +import com.google.api.client.json.GenericJson; +import com.google.api.client.json.JsonObjectParser; +import com.google.api.client.json.JsonParser; +import com.google.api.client.util.GenericData; +import com.google.common.base.Joiner; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import javax.annotation.Nullable; + +/** Implements the OAuth 2.0 token exchange based on https://tools.ietf.org/html/rfc8693. */ +final class StsRequestHandler { + private static final String TOKEN_EXCHANGE_GRANT_TYPE = + "urn:ietf:params:oauth:grant-type:token-exchange"; + private static final String REQUESTED_TOKEN_TYPE = + "urn:ietf:params:oauth:token-type:access_token"; + private static final String PARSE_ERROR_PREFIX = "Error parsing token response."; + + private final String tokenExchangeEndpoint; + private final StsTokenExchangeRequest request; + private final HttpRequestFactory httpRequestFactory; + + @Nullable private final HttpHeaders headers; + @Nullable private final String internalOptions; + + /** + * Internal constructor. + * + * @param tokenExchangeEndpoint the token exchange endpoint + * @param request the token exchange request + * @param headers optional additional headers to pass along the request + * @param internalOptions optional GCP specific STS options + * @return an StsTokenExchangeResponse instance if the request was successful + */ + private StsRequestHandler( + String tokenExchangeEndpoint, + StsTokenExchangeRequest request, + HttpRequestFactory httpRequestFactory, + @Nullable HttpHeaders headers, + @Nullable String internalOptions) { + this.tokenExchangeEndpoint = tokenExchangeEndpoint; + this.request = request; + this.httpRequestFactory = httpRequestFactory; + this.headers = headers; + this.internalOptions = internalOptions; + } + + public static Builder newBuilder( + String tokenExchangeEndpoint, + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpRequestFactory httpRequestFactory) { + return new Builder(tokenExchangeEndpoint, stsTokenExchangeRequest, httpRequestFactory); + } + + /** Exchanges the provided token for another type of token based on the RFC 8693 spec. */ + public StsTokenExchangeResponse exchangeToken() throws IOException { + UrlEncodedContent content = new UrlEncodedContent(buildTokenRequest()); + + HttpRequest httpRequest = + httpRequestFactory.buildPostRequest(new GenericUrl(tokenExchangeEndpoint), content); + httpRequest.setParser(new JsonObjectParser(OAuth2Utils.JSON_FACTORY)); + if (headers != null) { + httpRequest.setHeaders(headers); + } + + try { + HttpResponse response = httpRequest.execute(); + GenericData responseData = response.parseAs(GenericData.class); + return buildResponse(responseData); + } catch (HttpResponseException e) { + GenericJson errorResponse = parseJson((e).getContent()); + String errorCode = (String) errorResponse.get("error"); + String errorDescription = null; + String errorUri = null; + if (errorResponse.containsKey("error_description")) { + errorDescription = (String) errorResponse.get("error_description"); + } + if (errorResponse.containsKey("error_uri")) { + errorUri = (String) errorResponse.get("error_uri"); + } + throw new OAuthException(errorCode, errorDescription, errorUri); + } + } + + private GenericData buildTokenRequest() { + GenericData tokenRequest = + new GenericData() + .set("grant_type", TOKEN_EXCHANGE_GRANT_TYPE) + .set("subject_token_type", request.getSubjectTokenType()) + .set("subject_token", request.getSubjectToken()); + + // Add scopes as a space-delimited string. + List scopes = new ArrayList<>(); + if (request.hasScopes()) { + scopes.addAll(request.getScopes()); + tokenRequest.set("scope", Joiner.on(' ').join(scopes)); + } + + // Set the requested token type, which defaults to + // urn:ietf:params:oauth:token-type:access_token. + String requestTokenType = + request.hasRequestedTokenType() ? request.getRequestedTokenType() : REQUESTED_TOKEN_TYPE; + tokenRequest.set("requested_token_type", requestTokenType); + + // Add other optional params, if possible. + if (request.hasResource()) { + tokenRequest.set("resource", request.getResource()); + } + if (request.hasAudience()) { + tokenRequest.set("audience", request.getAudience()); + } + + if (request.hasActingParty()) { + tokenRequest.set("actor_token", request.getActingParty().getActorToken()); + tokenRequest.set("actor_token_type", request.getActingParty().getActorTokenType()); + } + + if (internalOptions != null && !internalOptions.isEmpty()) { + tokenRequest.set("options", internalOptions); + } + return tokenRequest; + } + + private StsTokenExchangeResponse buildResponse(GenericData responseData) throws IOException { + String accessToken = + OAuth2Utils.validateString(responseData, "access_token", PARSE_ERROR_PREFIX); + String issuedTokenType = + OAuth2Utils.validateString(responseData, "issued_token_type", PARSE_ERROR_PREFIX); + String tokenType = OAuth2Utils.validateString(responseData, "token_type", PARSE_ERROR_PREFIX); + Long expiresInSeconds = + OAuth2Utils.validateLong(responseData, "expires_in", PARSE_ERROR_PREFIX); + + StsTokenExchangeResponse.Builder builder = + StsTokenExchangeResponse.newBuilder( + accessToken, issuedTokenType, tokenType, expiresInSeconds); + + if (responseData.containsKey("refresh_token")) { + builder.setRefreshToken( + OAuth2Utils.validateString(responseData, "refresh_token", PARSE_ERROR_PREFIX)); + } + if (responseData.containsKey("scope")) { + String scope = OAuth2Utils.validateString(responseData, "scope", PARSE_ERROR_PREFIX); + builder.setScopes(Arrays.asList(scope.trim().split("\\s+"))); + } + return builder.build(); + } + + private GenericJson parseJson(String json) throws IOException { + JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser(json); + return parser.parseAndClose(GenericJson.class); + } + + public static class Builder { + private final String tokenExchangeEndpoint; + private final StsTokenExchangeRequest request; + private final HttpRequestFactory httpRequestFactory; + + @Nullable private HttpHeaders headers; + @Nullable private String internalOptions; + + private Builder( + String tokenExchangeEndpoint, + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpRequestFactory httpRequestFactory) { + this.tokenExchangeEndpoint = tokenExchangeEndpoint; + this.request = stsTokenExchangeRequest; + this.httpRequestFactory = httpRequestFactory; + } + + public StsRequestHandler.Builder setHeaders(HttpHeaders headers) { + this.headers = headers; + return this; + } + + public StsRequestHandler.Builder setInternalOptions(String internalOptions) { + this.internalOptions = internalOptions; + return this; + } + + public StsRequestHandler build() { + return new StsRequestHandler( + tokenExchangeEndpoint, request, httpRequestFactory, headers, internalOptions); + } + } +} diff --git a/oauth2_http/java/com/google/auth/oauth2/StsTokenExchangeRequest.java b/oauth2_http/java/com/google/auth/oauth2/StsTokenExchangeRequest.java new file mode 100644 index 000000000..b9525bd68 --- /dev/null +++ b/oauth2_http/java/com/google/auth/oauth2/StsTokenExchangeRequest.java @@ -0,0 +1,184 @@ +/* + * Copyright 2021 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import static com.google.common.base.Preconditions.checkNotNull; + +import java.util.List; +import javax.annotation.Nullable; + +/** + * Defines an OAuth 2.0 token exchange request. Based on + * https://tools.ietf.org/html/rfc8693#section-2.1. + */ +final class StsTokenExchangeRequest { + private static final String GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"; + + private final String subjectToken; + private final String subjectTokenType; + + @Nullable private final ActingParty actingParty; + @Nullable private final List scopes; + @Nullable private final String resource; + @Nullable private final String audience; + @Nullable private final String requestedTokenType; + + private StsTokenExchangeRequest( + String subjectToken, + String subjectTokenType, + @Nullable ActingParty actingParty, + @Nullable List scopes, + @Nullable String resource, + @Nullable String audience, + @Nullable String requestedTokenType) { + this.subjectToken = checkNotNull(subjectToken); + this.subjectTokenType = checkNotNull(subjectTokenType); + this.actingParty = actingParty; + this.scopes = scopes; + this.resource = resource; + this.audience = audience; + this.requestedTokenType = requestedTokenType; + } + + public static Builder newBuilder(String subjectToken, String subjectTokenType) { + return new Builder(subjectToken, subjectTokenType); + } + + public String getGrantType() { + return GRANT_TYPE; + } + + public String getSubjectToken() { + return subjectToken; + } + + public String getSubjectTokenType() { + return subjectTokenType; + } + + @Nullable + public String getResource() { + return resource; + } + + @Nullable + public String getAudience() { + return audience; + } + + @Nullable + public String getRequestedTokenType() { + return requestedTokenType; + } + + @Nullable + public List getScopes() { + return scopes; + } + + @Nullable + public ActingParty getActingParty() { + return actingParty; + } + + public boolean hasResource() { + return resource != null && !resource.isEmpty(); + } + + public boolean hasAudience() { + return audience != null && !audience.isEmpty(); + } + + public boolean hasRequestedTokenType() { + return requestedTokenType != null && !requestedTokenType.isEmpty(); + } + + public boolean hasScopes() { + return scopes != null && !scopes.isEmpty(); + } + + public boolean hasActingParty() { + return actingParty != null; + } + + public static class Builder { + private final String subjectToken; + private final String subjectTokenType; + + @Nullable private String resource; + @Nullable private String audience; + @Nullable private String requestedTokenType; + @Nullable private List scopes; + @Nullable private ActingParty actingParty; + + private Builder(String subjectToken, String subjectTokenType) { + this.subjectToken = subjectToken; + this.subjectTokenType = subjectTokenType; + } + + public StsTokenExchangeRequest.Builder setResource(String resource) { + this.resource = resource; + return this; + } + + public StsTokenExchangeRequest.Builder setAudience(String audience) { + this.audience = audience; + return this; + } + + public StsTokenExchangeRequest.Builder setRequestTokenType(String requestedTokenType) { + this.requestedTokenType = requestedTokenType; + return this; + } + + public StsTokenExchangeRequest.Builder setScopes(List scopes) { + this.scopes = scopes; + return this; + } + + public StsTokenExchangeRequest.Builder setActingParty(ActingParty actingParty) { + this.actingParty = actingParty; + return this; + } + + public StsTokenExchangeRequest build() { + return new StsTokenExchangeRequest( + subjectToken, + subjectTokenType, + actingParty, + scopes, + resource, + audience, + requestedTokenType); + } + } +} diff --git a/oauth2_http/java/com/google/auth/oauth2/StsTokenExchangeResponse.java b/oauth2_http/java/com/google/auth/oauth2/StsTokenExchangeResponse.java new file mode 100644 index 000000000..a16f5a329 --- /dev/null +++ b/oauth2_http/java/com/google/auth/oauth2/StsTokenExchangeResponse.java @@ -0,0 +1,139 @@ +/* + * Copyright 2021 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import static com.google.common.base.Preconditions.checkNotNull; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import javax.annotation.Nullable; + +/** + * Defines an OAuth 2.0 token exchange successful response. Based on + * https://tools.ietf.org/html/rfc8693#section-2.2.1. + */ +final class StsTokenExchangeResponse { + private final AccessToken accessToken; + private final String issuedTokenType; + private final String tokenType; + private final Long expiresInSeconds; + + @Nullable private final String refreshToken; + @Nullable private final List scopes; + + private StsTokenExchangeResponse( + String accessToken, + String issuedTokenType, + String tokenType, + Long expiresInSeconds, + @Nullable String refreshToken, + @Nullable List scopes) { + checkNotNull(accessToken); + this.expiresInSeconds = checkNotNull(expiresInSeconds); + long expiresAtMilliseconds = System.currentTimeMillis() + expiresInSeconds * 1000L; + this.accessToken = new AccessToken(accessToken, new Date(expiresAtMilliseconds)); + this.issuedTokenType = checkNotNull(issuedTokenType); + this.tokenType = checkNotNull(tokenType); + this.refreshToken = refreshToken; + this.scopes = scopes; + } + + public static Builder newBuilder( + String accessToken, String issuedTokenType, String tokenType, Long expiresIn) { + return new Builder(accessToken, issuedTokenType, tokenType, expiresIn); + } + + public AccessToken getAccessToken() { + return accessToken; + } + + public String getIssuedTokenType() { + return issuedTokenType; + } + + public String getTokenType() { + return tokenType; + } + + public Long getExpiresInSeconds() { + return expiresInSeconds; + } + + @Nullable + public String getRefreshToken() { + return refreshToken; + } + + @Nullable + public List getScopes() { + if (scopes == null) { + return null; + } + return new ArrayList<>(scopes); + } + + public static class Builder { + private final String accessToken; + private final String issuedTokenType; + private final String tokenType; + private final Long expiresInSeconds; + + @Nullable private String refreshToken; + @Nullable private List scopes; + + private Builder( + String accessToken, String issuedTokenType, String tokenType, Long expiresInSeconds) { + this.accessToken = accessToken; + this.issuedTokenType = issuedTokenType; + this.tokenType = tokenType; + this.expiresInSeconds = expiresInSeconds; + } + + public StsTokenExchangeResponse.Builder setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + return this; + } + + public StsTokenExchangeResponse.Builder setScopes(List scopes) { + if (scopes != null) { + this.scopes = new ArrayList<>(scopes); + } + return this; + } + + public StsTokenExchangeResponse build() { + return new StsTokenExchangeResponse( + accessToken, issuedTokenType, tokenType, expiresInSeconds, refreshToken, scopes); + } + } +} diff --git a/oauth2_http/javatests/com/google/auth/TestUtils.java b/oauth2_http/javatests/com/google/auth/TestUtils.java index 5f43dbba4..b9c2b6d75 100644 --- a/oauth2_http/javatests/com/google/auth/TestUtils.java +++ b/oauth2_http/javatests/com/google/auth/TestUtils.java @@ -34,6 +34,8 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; +import com.google.api.client.http.HttpHeaders; +import com.google.api.client.http.HttpResponseException; import com.google.api.client.json.GenericJson; import com.google.api.client.json.JsonFactory; import com.google.api.client.json.gson.GsonFactory; @@ -45,15 +47,17 @@ import java.io.InputStream; import java.io.UnsupportedEncodingException; import java.net.URLDecoder; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; +import javax.annotation.Nullable; /** Utilities for test code under com.google.auth. */ public class TestUtils { - public static final String UTF_8 = "UTF-8"; - private static final JsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance(); public static void assertContainsBearerToken(Map> metadata, String token) { @@ -84,12 +88,12 @@ private static boolean hasBearerToken(Map> metadata, String public static InputStream jsonToInputStream(GenericJson json) throws IOException { json.setFactory(JSON_FACTORY); String text = json.toPrettyString(); - return new ByteArrayInputStream(text.getBytes(UTF_8)); + return new ByteArrayInputStream(text.getBytes("UTF-8")); } public static InputStream stringToInputStream(String text) { try { - return new ByteArrayInputStream(text.getBytes(TestUtils.UTF_8)); + return new ByteArrayInputStream(text.getBytes("UTF-8")); } catch (UnsupportedEncodingException e) { throw new RuntimeException("Unexpected encoding exception", e); } @@ -103,8 +107,8 @@ public static Map parseQuery(String query) throws IOException { if (sides.size() != 2) { throw new IOException("Invalid Query String"); } - String key = URLDecoder.decode(sides.get(0), UTF_8); - String value = URLDecoder.decode(sides.get(1), UTF_8); + String key = URLDecoder.decode(sides.get(0), "UTF-8"); + String value = URLDecoder.decode(sides.get(1), "UTF-8"); map.put(key, value); } return map; @@ -119,5 +123,30 @@ public static String errorJson(String message) throws IOException { return errorResponse.toPrettyString(); } + public static HttpResponseException buildHttpResponseException( + String error, @Nullable String errorDescription, @Nullable String errorUri) + throws IOException { + GenericJson json = new GenericJson(); + json.setFactory(GsonFactory.getDefaultInstance()); + json.set("error", error); + if (errorDescription != null) { + json.set("error_description", errorDescription); + } + if (errorUri != null) { + json.set("error_uri", errorUri); + } + return new HttpResponseException.Builder( + /* statusCode= */ 400, /* statusMessage= */ "statusMessage", new HttpHeaders()) + .setContent(json.toPrettyString()) + .build(); + } + + public static String getDefaultExpireTime() { + Calendar calendar = Calendar.getInstance(); + calendar.setTime(new Date()); + calendar.add(Calendar.SECOND, 300); + return new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'").format(calendar.getTime()); + } + private TestUtils() {} } diff --git a/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java new file mode 100644 index 000000000..dc86a516f --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java @@ -0,0 +1,539 @@ +/* + * Copyright 2021 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.google.api.client.json.GenericJson; +import com.google.api.client.json.JsonParser; +import com.google.auth.TestUtils; +import com.google.auth.http.HttpTransportFactory; +import com.google.auth.oauth2.AwsCredentials.AwsCredentialSource; +import com.google.auth.oauth2.ExternalAccountCredentialsTest.MockExternalAccountCredentialsTransportFactory; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URLDecoder; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.annotation.Nullable; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Tests for {@link AwsCredentials}. */ +@RunWith(JUnit4.class) +public class AwsCredentialsTest { + + private static final String GET_CALLER_IDENTITY_URL = + "https://sts.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15"; + + private static final String SERVICE_ACCOUNT_IMPERSONATION_URL = + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/testn@test.iam.gserviceaccount.com:generateAccessToken"; + + private static final Map AWS_CREDENTIAL_SOURCE_MAP = + new HashMap() { + { + put("environment_id", "aws1"); + put("region_url", "regionUrl"); + put("url", "url"); + put("regional_cred_verification_url", "regionalCredVerificationUrl"); + } + }; + + private static final AwsCredentialSource AWS_CREDENTIAL_SOURCE = + new AwsCredentialSource(AWS_CREDENTIAL_SOURCE_MAP); + + private static final AwsCredentials AWS_CREDENTIAL = + (AwsCredentials) + AwsCredentials.newBuilder() + .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("tokenUrl") + .setTokenInfoUrl("tokenInfoUrl") + .setCredentialSource(AWS_CREDENTIAL_SOURCE) + .build(); + + @Test + public void refreshAccessToken_withoutServiceAccountImpersonation() throws IOException { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + AwsCredentials awsCredential = + (AwsCredentials) + AwsCredentials.newBuilder(AWS_CREDENTIAL) + .setTokenUrl(transportFactory.transport.getStsUrl()) + .setHttpTransportFactory(transportFactory) + .setCredentialSource(buildAwsCredentialSource(transportFactory)) + .build(); + + AccessToken accessToken = awsCredential.refreshAccessToken(); + + assertEquals(transportFactory.transport.getAccessToken(), accessToken.getTokenValue()); + } + + @Test + public void refreshAccessToken_withServiceAccountImpersonation() throws IOException { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + transportFactory.transport.setExpireTime(TestUtils.getDefaultExpireTime()); + + AwsCredentials awsCredential = + (AwsCredentials) + AwsCredentials.newBuilder(AWS_CREDENTIAL) + .setTokenUrl(transportFactory.transport.getStsUrl()) + .setServiceAccountImpersonationUrl( + transportFactory.transport.getServiceAccountImpersonationUrl()) + .setHttpTransportFactory(transportFactory) + .setCredentialSource(buildAwsCredentialSource(transportFactory)) + .build(); + + AccessToken accessToken = awsCredential.refreshAccessToken(); + + assertEquals( + transportFactory.transport.getServiceAccountAccessToken(), accessToken.getTokenValue()); + } + + @Test + public void retrieveSubjectToken() throws IOException { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + AwsCredentials awsCredential = + (AwsCredentials) + AwsCredentials.newBuilder(AWS_CREDENTIAL) + .setHttpTransportFactory(transportFactory) + .setCredentialSource(buildAwsCredentialSource(transportFactory)) + .build(); + + String subjectToken = URLDecoder.decode(awsCredential.retrieveSubjectToken(), "UTF-8"); + + JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser(subjectToken); + GenericJson json = parser.parseAndClose(GenericJson.class); + + List> headersList = (List>) json.get("headers"); + Map headers = new HashMap<>(); + for (Map header : headersList) { + headers.put(header.get("key"), header.get("value")); + } + + assertEquals("POST", json.get("method")); + assertEquals(GET_CALLER_IDENTITY_URL, json.get("url")); + assertEquals(URI.create(GET_CALLER_IDENTITY_URL).getHost(), headers.get("host")); + assertEquals("token", headers.get("x-amz-security-token")); + assertEquals(awsCredential.getAudience(), headers.get("x-goog-cloud-target-resource")); + assertTrue(headers.containsKey("x-amz-date")); + assertNotNull(headers.get("Authorization")); + } + + @Test + public void retrieveSubjectToken_noRegion_expectThrows() { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + IOException response = new IOException(); + transportFactory.transport.addResponseErrorSequence(response); + + AwsCredentials awsCredential = + (AwsCredentials) + AwsCredentials.newBuilder(AWS_CREDENTIAL) + .setHttpTransportFactory(transportFactory) + .setCredentialSource(buildAwsCredentialSource(transportFactory)) + .build(); + + try { + awsCredential.retrieveSubjectToken(); + fail("Exception should be thrown."); + } catch (IOException e) { + assertEquals("Failed to retrieve AWS region.", e.getMessage()); + } + } + + @Test + public void retrieveSubjectToken_noRole_expectThrows() { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + IOException response = new IOException(); + transportFactory.transport.addResponseErrorSequence(response); + transportFactory.transport.addResponseSequence(true, false); + + AwsCredentials awsCredential = + (AwsCredentials) + AwsCredentials.newBuilder(AWS_CREDENTIAL) + .setHttpTransportFactory(transportFactory) + .setCredentialSource(buildAwsCredentialSource(transportFactory)) + .build(); + + try { + awsCredential.retrieveSubjectToken(); + fail("Exception should be thrown."); + } catch (IOException e) { + assertEquals("Failed to retrieve AWS IAM role.", e.getMessage()); + } + } + + @Test + public void retrieveSubjectToken_noCredentials_expectThrows() { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + IOException response = new IOException(); + transportFactory.transport.addResponseErrorSequence(response); + transportFactory.transport.addResponseSequence(true, true, false); + + AwsCredentials awsCredential = + (AwsCredentials) + AwsCredentials.newBuilder(AWS_CREDENTIAL) + .setHttpTransportFactory(transportFactory) + .setCredentialSource(buildAwsCredentialSource(transportFactory)) + .build(); + + try { + awsCredential.retrieveSubjectToken(); + fail("Exception should be thrown."); + } catch (IOException e) { + assertEquals("Failed to retrieve AWS credentials.", e.getMessage()); + } + } + + @Test + public void retrieveSubjectToken_noRegionUrlProvided() { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + Map credentialSource = new HashMap<>(); + credentialSource.put("environment_id", "aws1"); + credentialSource.put("regional_cred_verification_url", GET_CALLER_IDENTITY_URL); + + AwsCredentials awsCredential = + (AwsCredentials) + AwsCredentials.newBuilder(AWS_CREDENTIAL) + .setHttpTransportFactory(transportFactory) + .setCredentialSource(new AwsCredentialSource(credentialSource)) + .build(); + + try { + awsCredential.retrieveSubjectToken(); + fail("Exception should be thrown."); + } catch (IOException e) { + assertEquals( + "Unable to determine the AWS region. The credential source does not " + + "contain the region URL.", + e.getMessage()); + } + } + + @Test + public void getAwsSecurityCredentials_fromEnvironmentVariablesNoToken() throws IOException { + TestAwsCredentials testAwsCredentials = TestAwsCredentials.newBuilder(AWS_CREDENTIAL).build(); + testAwsCredentials.setEnv("AWS_ACCESS_KEY_ID", "awsAccessKeyId"); + testAwsCredentials.setEnv("AWS_SECRET_ACCESS_KEY", "awsSecretAccessKey"); + + AwsSecurityCredentials credentials = testAwsCredentials.getAwsSecurityCredentials(); + + assertEquals("awsAccessKeyId", credentials.getAccessKeyId()); + assertEquals("awsSecretAccessKey", credentials.getSecretAccessKey()); + assertNull(credentials.getToken()); + } + + @Test + public void getAwsSecurityCredentials_fromEnvironmentVariablesWithToken() throws IOException { + TestAwsCredentials testAwsCredentials = TestAwsCredentials.newBuilder(AWS_CREDENTIAL).build(); + testAwsCredentials.setEnv("AWS_ACCESS_KEY_ID", "awsAccessKeyId"); + testAwsCredentials.setEnv("AWS_SECRET_ACCESS_KEY", "awsSecretAccessKey"); + testAwsCredentials.setEnv("Token", "token"); + + AwsSecurityCredentials credentials = testAwsCredentials.getAwsSecurityCredentials(); + + assertEquals("awsAccessKeyId", credentials.getAccessKeyId()); + assertEquals("awsSecretAccessKey", credentials.getSecretAccessKey()); + assertEquals("token", credentials.getToken()); + } + + @Test + public void getAwsSecurityCredentials_fromMetadataServer() throws IOException { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + AwsCredentials awsCredential = + (AwsCredentials) + AwsCredentials.newBuilder(AWS_CREDENTIAL) + .setHttpTransportFactory(transportFactory) + .setCredentialSource(buildAwsCredentialSource(transportFactory)) + .build(); + + AwsSecurityCredentials credentials = awsCredential.getAwsSecurityCredentials(); + + assertEquals("accessKeyId", credentials.getAccessKeyId()); + assertEquals("secretAccessKey", credentials.getSecretAccessKey()); + assertEquals("token", credentials.getToken()); + } + + @Test + public void getAwsSecurityCredentials_fromMetadataServer_noUrlProvided() { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + Map credentialSource = new HashMap<>(); + credentialSource.put("environment_id", "aws1"); + credentialSource.put("regional_cred_verification_url", GET_CALLER_IDENTITY_URL); + + AwsCredentials awsCredential = + (AwsCredentials) + AwsCredentials.newBuilder(AWS_CREDENTIAL) + .setHttpTransportFactory(transportFactory) + .setCredentialSource(new AwsCredentialSource(credentialSource)) + .build(); + + try { + awsCredential.getAwsSecurityCredentials(); + fail("Exception should be thrown."); + } catch (IOException e) { + assertEquals( + "Unable to determine the AWS IAM role name. The credential source does not contain the url field.", + e.getMessage()); + } + } + + @Test + public void createdScoped_clonedCredentialWithAddedScopes() { + AwsCredentials credentials = + (AwsCredentials) + AwsCredentials.newBuilder(AWS_CREDENTIAL) + .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL) + .setQuotaProjectId("quotaProjectId") + .setClientId("clientId") + .setClientSecret("clientSecret") + .build(); + + List newScopes = Arrays.asList("scope1", "scope2"); + + AwsCredentials newCredentials = (AwsCredentials) credentials.createScoped(newScopes); + + assertEquals(credentials.getAudience(), newCredentials.getAudience()); + assertEquals(credentials.getSubjectTokenType(), newCredentials.getSubjectTokenType()); + assertEquals(credentials.getTokenUrl(), newCredentials.getTokenUrl()); + assertEquals(credentials.getTokenInfoUrl(), newCredentials.getTokenInfoUrl()); + assertEquals( + credentials.getServiceAccountImpersonationUrl(), + newCredentials.getServiceAccountImpersonationUrl()); + assertEquals(credentials.getCredentialSource(), newCredentials.getCredentialSource()); + assertEquals(credentials.getQuotaProjectId(), newCredentials.getQuotaProjectId()); + assertEquals(credentials.getClientId(), newCredentials.getClientId()); + assertEquals(credentials.getClientSecret(), newCredentials.getClientSecret()); + assertEquals(newScopes, newCredentials.getScopes()); + } + + @Test + public void credentialSource_invalidAwsEnvironmentId() { + Map credentialSource = new HashMap<>(); + credentialSource.put("regional_cred_verification_url", GET_CALLER_IDENTITY_URL); + credentialSource.put("environment_id", "azure1"); + + try { + new AwsCredentialSource(credentialSource); + fail("Exception should be thrown."); + } catch (IllegalArgumentException e) { + assertEquals("Invalid AWS environment ID.", e.getMessage()); + } + } + + @Test + public void credentialSource_invalidAwsEnvironmentVersion() { + Map credentialSource = new HashMap<>(); + int environmentVersion = 2; + credentialSource.put("regional_cred_verification_url", GET_CALLER_IDENTITY_URL); + credentialSource.put("environment_id", "aws" + environmentVersion); + + try { + new AwsCredentialSource(credentialSource); + fail("Exception should be thrown."); + } catch (IllegalArgumentException e) { + assertEquals( + String.format( + "AWS version %s is not supported in the current build.", environmentVersion), + e.getMessage()); + } + } + + @Test + public void credentialSource_missingRegionalCredVerificationUrl() { + try { + new AwsCredentialSource(new HashMap()); + fail("Exception should be thrown."); + } catch (IllegalArgumentException e) { + assertEquals( + "A regional_cred_verification_url representing the GetCallerIdentity action URL must be specified.", + e.getMessage()); + } + } + + @Test + public void builder() { + List scopes = Arrays.asList("scope1", "scope2"); + + AwsCredentials credentials = + (AwsCredentials) + AwsCredentials.newBuilder() + .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("tokenUrl") + .setCredentialSource(AWS_CREDENTIAL_SOURCE) + .setTokenInfoUrl("tokenInfoUrl") + .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL) + .setQuotaProjectId("quotaProjectId") + .setClientId("clientId") + .setClientSecret("clientSecret") + .setScopes(scopes) + .build(); + + assertEquals("audience", credentials.getAudience()); + assertEquals("subjectTokenType", credentials.getSubjectTokenType()); + assertEquals(credentials.getTokenUrl(), "tokenUrl"); + assertEquals(credentials.getTokenInfoUrl(), "tokenInfoUrl"); + assertEquals( + credentials.getServiceAccountImpersonationUrl(), SERVICE_ACCOUNT_IMPERSONATION_URL); + assertEquals(credentials.getCredentialSource(), AWS_CREDENTIAL_SOURCE); + assertEquals(credentials.getQuotaProjectId(), "quotaProjectId"); + assertEquals(credentials.getClientId(), "clientId"); + assertEquals(credentials.getClientSecret(), "clientSecret"); + assertEquals(credentials.getScopes(), scopes); + } + + private static AwsCredentialSource buildAwsCredentialSource( + MockExternalAccountCredentialsTransportFactory transportFactory) { + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("environment_id", "aws1"); + credentialSourceMap.put("region_url", transportFactory.transport.getAwsRegionUrl()); + credentialSourceMap.put("url", transportFactory.transport.getAwsCredentialsUrl()); + credentialSourceMap.put("regional_cred_verification_url", GET_CALLER_IDENTITY_URL); + return new AwsCredentialSource(credentialSourceMap); + } + + static InputStream writeAwsCredentialsStream(String stsUrl, String regionUrl, String metadataUrl) + throws IOException { + GenericJson json = new GenericJson(); + json.put("audience", "audience"); + json.put("subject_token_type", "subjectTokenType"); + json.put("token_url", stsUrl); + json.put("token_info_url", "tokenInfoUrl"); + json.put("type", ExternalAccountCredentials.EXTERNAL_ACCOUNT_FILE_TYPE); + + GenericJson credentialSource = new GenericJson(); + credentialSource.put("environment_id", "aws1"); + credentialSource.put("region_url", regionUrl); + credentialSource.put("url", metadataUrl); + credentialSource.put("regional_cred_verification_url", GET_CALLER_IDENTITY_URL); + json.put("credential_source", credentialSource); + + return TestUtils.jsonToInputStream(json); + } + + /** Used to test the retrieval of AWS credentials from environment variables. */ + private static class TestAwsCredentials extends AwsCredentials { + + private final Map environmentVariables = new HashMap<>(); + + TestAwsCredentials( + HttpTransportFactory transportFactory, + String audience, + String subjectTokenType, + String tokenUrl, + String tokenInfoUrl, + AwsCredentialSource credentialSource, + @Nullable String serviceAccountImpersonationUrl, + @Nullable String quotaProjectId, + @Nullable String clientId, + @Nullable String clientSecret, + @Nullable Collection scopes) { + super( + transportFactory, + audience, + subjectTokenType, + tokenUrl, + credentialSource, + tokenInfoUrl, + serviceAccountImpersonationUrl, + quotaProjectId, + clientId, + clientSecret, + scopes); + } + + public static TestAwsCredentials.Builder newBuilder(AwsCredentials awsCredentials) { + return new TestAwsCredentials.Builder(awsCredentials); + } + + public static class Builder extends AwsCredentials.Builder { + + private Builder(AwsCredentials credentials) { + super(credentials); + } + + @Override + public TestAwsCredentials build() { + return new TestAwsCredentials( + transportFactory, + audience, + subjectTokenType, + tokenUrl, + tokenInfoUrl, + (AwsCredentialSource) credentialSource, + serviceAccountImpersonationUrl, + quotaProjectId, + clientId, + clientSecret, + scopes); + } + } + + @Override + String getEnv(String name) { + return environmentVariables.get(name); + } + + void setEnv(String name, String value) { + environmentVariables.put(name, value); + } + } +} diff --git a/oauth2_http/javatests/com/google/auth/oauth2/AwsRequestSignerTest.java b/oauth2_http/javatests/com/google/auth/oauth2/AwsRequestSignerTest.java new file mode 100644 index 000000000..ebb14091e --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/oauth2/AwsRequestSignerTest.java @@ -0,0 +1,544 @@ +/* + * Copyright 2021 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import static org.junit.Assert.assertEquals; + +import com.google.api.client.json.GenericJson; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.JsonObjectParser; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; + +/** + * Tests for {@link AwsRequestSigner}. + * + *

Examples of sigv4 signed requests: + * https://docs.aws.amazon.com/general/latest/gr/sigv4-signed-request-examples.html + */ +public class AwsRequestSignerTest { + + private static final String DATE = "Mon, 09 Sep 2011 23:36:00 GMT"; + private static final String X_AMZ_DATE = "20200811T065522Z"; + + private static final AwsSecurityCredentials BOTOCORE_CREDENTIALS = + new AwsSecurityCredentials( + "AKIDEXAMPLE", "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", /* token= */ null); + + private AwsSecurityCredentials awsSecurityCredentials; + + @Before + public void setUp() throws IOException { + awsSecurityCredentials = retrieveAwsSecurityCredentials(); + } + + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-vanilla.req + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-vanilla.sreq + @Test + public void sign_getHost() { + String url = "https://host.foo.com"; + + Map headers = new HashMap<>(); + headers.put("date", DATE); + + AwsRequestSigner signer = + AwsRequestSigner.newBuilder(BOTOCORE_CREDENTIALS, "GET", url, "us-east-1") + .setAdditionalHeaders(headers) + .build(); + + AwsRequestSignature signature = signer.sign(); + + String expectedSignature = "b27ccfbfa7df52a200ff74193ca6e32d4b48b8856fab7ebf1c595d0670a7e470"; + String expectedAuthHeader = + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/" + + "aws4_request, SignedHeaders=date;host, Signature=" + + expectedSignature; + + assertEquals(expectedSignature, signature.getSignature()); + assertEquals(expectedAuthHeader, signature.getAuthorizationHeader()); + assertEquals(BOTOCORE_CREDENTIALS, signature.getSecurityCredentials()); + assertEquals(DATE, signature.getDate()); + assertEquals("GET", signature.getHttpMethod()); + assertEquals("us-east-1", signature.getRegion()); + assertEquals(URI.create(url).normalize().toString(), signature.getUrl()); + } + + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-relative-relative.req + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-relative-relative.sreq + @Test + public void sign_getHostRelativePath() { + String url = "https://host.foo.com/foo/bar/../.."; + + Map headers = new HashMap<>(); + headers.put("date", DATE); + + AwsRequestSigner signer = + AwsRequestSigner.newBuilder(BOTOCORE_CREDENTIALS, "GET", url, "us-east-1") + .setAdditionalHeaders(headers) + .build(); + + AwsRequestSignature signature = signer.sign(); + + String expectedSignature = "b27ccfbfa7df52a200ff74193ca6e32d4b48b8856fab7ebf1c595d0670a7e470"; + String expectedAuthHeader = + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/" + + "aws4_request, SignedHeaders=date;host, Signature=" + + expectedSignature; + + assertEquals(expectedSignature, signature.getSignature()); + assertEquals(expectedAuthHeader, signature.getAuthorizationHeader()); + assertEquals(BOTOCORE_CREDENTIALS, signature.getSecurityCredentials()); + assertEquals(DATE, signature.getDate()); + assertEquals("GET", signature.getHttpMethod()); + assertEquals("us-east-1", signature.getRegion()); + assertEquals(URI.create(url).normalize().toString(), signature.getUrl()); + } + + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-slash-dot-slash.req + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-slash-dot-slash.sreq + @Test + public void sign_getHostInvalidPath() { + String url = "https://host.foo.com/./"; + + Map headers = new HashMap<>(); + headers.put("date", DATE); + + AwsRequestSigner signer = + AwsRequestSigner.newBuilder(BOTOCORE_CREDENTIALS, "GET", url, "us-east-1") + .setAdditionalHeaders(headers) + .build(); + + AwsRequestSignature signature = signer.sign(); + + String expectedSignature = "b27ccfbfa7df52a200ff74193ca6e32d4b48b8856fab7ebf1c595d0670a7e470"; + String expectedAuthHeader = + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/" + + "aws4_request, SignedHeaders=date;host, Signature=" + + expectedSignature; + + assertEquals(expectedSignature, signature.getSignature()); + assertEquals(expectedAuthHeader, signature.getAuthorizationHeader()); + assertEquals(BOTOCORE_CREDENTIALS, signature.getSecurityCredentials()); + assertEquals(DATE, signature.getDate()); + assertEquals("GET", signature.getHttpMethod()); + assertEquals("us-east-1", signature.getRegion()); + assertEquals(URI.create(url).normalize().toString(), signature.getUrl()); + } + + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-slash-pointless-dot.req + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-slash-pointless-dot.sreq + @Test + public void sign_getHostDotPath() { + String url = "https://host.foo.com/./foo"; + + Map headers = new HashMap<>(); + headers.put("date", DATE); + + AwsRequestSigner signer = + AwsRequestSigner.newBuilder(BOTOCORE_CREDENTIALS, "GET", url, "us-east-1") + .setAdditionalHeaders(headers) + .build(); + + AwsRequestSignature signature = signer.sign(); + + String expectedSignature = "910e4d6c9abafaf87898e1eb4c929135782ea25bb0279703146455745391e63a"; + String expectedAuthHeader = + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/" + + "aws4_request, SignedHeaders=date;host, Signature=" + + expectedSignature; + + assertEquals(expectedSignature, signature.getSignature()); + assertEquals(expectedAuthHeader, signature.getAuthorizationHeader()); + assertEquals(BOTOCORE_CREDENTIALS, signature.getSecurityCredentials()); + assertEquals(DATE, signature.getDate()); + assertEquals("GET", signature.getHttpMethod()); + assertEquals("us-east-1", signature.getRegion()); + assertEquals(URI.create(url).normalize().toString(), signature.getUrl()); + } + + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-utf8.req + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-utf8.sreq + @Test + public void sign_getHostUtf8Path() { + String url = "https://host.foo.com/%E1%88%B4"; + + Map headers = new HashMap<>(); + headers.put("date", DATE); + + AwsRequestSigner signer = + AwsRequestSigner.newBuilder(BOTOCORE_CREDENTIALS, "GET", url, "us-east-1") + .setAdditionalHeaders(headers) + .build(); + + AwsRequestSignature signature = signer.sign(); + + String expectedSignature = "8d6634c189aa8c75c2e51e106b6b5121bed103fdb351f7d7d4381c738823af74"; + String expectedAuthHeader = + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/" + + "aws4_request, SignedHeaders=date;host, Signature=" + + expectedSignature; + + assertEquals(expectedSignature, signature.getSignature()); + assertEquals(expectedAuthHeader, signature.getAuthorizationHeader()); + assertEquals(BOTOCORE_CREDENTIALS, signature.getSecurityCredentials()); + assertEquals(DATE, signature.getDate()); + assertEquals("GET", signature.getHttpMethod()); + assertEquals("us-east-1", signature.getRegion()); + assertEquals(URI.create(url).normalize().toString(), signature.getUrl()); + } + + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-vanilla-query-order-key-case.req + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-vanilla-query-order-key-case.sreq + @Test + public void sign_getHostDuplicateQueryParam() { + String url = "https://host.foo.com/?foo=Zoo&foo=aha"; + + Map headers = new HashMap<>(); + headers.put("date", DATE); + + AwsRequestSigner signer = + AwsRequestSigner.newBuilder(BOTOCORE_CREDENTIALS, "GET", url, "us-east-1") + .setAdditionalHeaders(headers) + .build(); + + AwsRequestSignature signature = signer.sign(); + + String expectedSignature = "be7148d34ebccdc6423b19085378aa0bee970bdc61d144bd1a8c48c33079ab09"; + String expectedAuthHeader = + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/" + + "aws4_request, SignedHeaders=date;host, Signature=" + + expectedSignature; + + assertEquals(expectedSignature, signature.getSignature()); + assertEquals(expectedAuthHeader, signature.getAuthorizationHeader()); + assertEquals(BOTOCORE_CREDENTIALS, signature.getSecurityCredentials()); + assertEquals(DATE, signature.getDate()); + assertEquals("GET", signature.getHttpMethod()); + assertEquals("us-east-1", signature.getRegion()); + assertEquals(URI.create(url).normalize().toString(), signature.getUrl()); + } + + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/post-header-key-sort.req + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/post-header-key-sort.sreq + @Test + public void sign_postWithUpperCaseHeaderKey() { + String url = "https://host.foo.com/"; + String headerKey = "ZOO"; + String headerValue = "zoobar"; + + Map headers = new HashMap<>(); + headers.put("date", DATE); + headers.put(headerKey, headerValue); + + AwsRequestSigner signer = + AwsRequestSigner.newBuilder(BOTOCORE_CREDENTIALS, "POST", url, "us-east-1") + .setAdditionalHeaders(headers) + .build(); + + AwsRequestSignature signature = signer.sign(); + + String expectedSignature = "b7a95a52518abbca0964a999a880429ab734f35ebbf1235bd79a5de87756dc4a"; + String expectedAuthHeader = + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/" + + "aws4_request, SignedHeaders=date;host;zoo, Signature=" + + expectedSignature; + + assertEquals(expectedSignature, signature.getSignature()); + assertEquals(expectedAuthHeader, signature.getAuthorizationHeader()); + assertEquals(BOTOCORE_CREDENTIALS, signature.getSecurityCredentials()); + assertEquals(DATE, signature.getDate()); + assertEquals("POST", signature.getHttpMethod()); + assertEquals("us-east-1", signature.getRegion()); + assertEquals(URI.create(url).normalize().toString(), signature.getUrl()); + assertEquals(headerValue, signature.getCanonicalHeaders().get(headerKey.toLowerCase())); + } + + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/post-header-value-case.req + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/post-header-value-case.sreq + @Test + public void sign_postWithUpperCaseHeaderValue() { + String url = "https://host.foo.com/"; + String headerKey = "zoo"; + String headerValue = "ZOOBAR"; + + Map headers = new HashMap<>(); + headers.put("date", DATE); + headers.put("zoo", "ZOOBAR"); + + AwsRequestSigner signer = + AwsRequestSigner.newBuilder(BOTOCORE_CREDENTIALS, "POST", url, "us-east-1") + .setAdditionalHeaders(headers) + .build(); + + AwsRequestSignature signature = signer.sign(); + + String expectedSignature = "273313af9d0c265c531e11db70bbd653f3ba074c1009239e8559d3987039cad7"; + String expectedAuthHeader = + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/" + + "aws4_request, SignedHeaders=date;host;zoo, Signature=" + + expectedSignature; + + assertEquals(expectedSignature, signature.getSignature()); + assertEquals(expectedAuthHeader, signature.getAuthorizationHeader()); + assertEquals(BOTOCORE_CREDENTIALS, signature.getSecurityCredentials()); + assertEquals(DATE, signature.getDate()); + assertEquals("POST", signature.getHttpMethod()); + assertEquals("us-east-1", signature.getRegion()); + assertEquals(URI.create(url).normalize().toString(), signature.getUrl()); + assertEquals(headerValue, signature.getCanonicalHeaders().get(headerKey.toLowerCase())); + } + + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-header-value-trim.req + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-header-value-trim.sreq + @Test + public void sign_postWithHeader() { + String url = "https://host.foo.com/"; + String headerKey = "p"; + String headerValue = "phfft"; + + Map headers = new HashMap<>(); + headers.put("date", DATE); + headers.put(headerKey, headerValue); + + AwsRequestSigner signer = + AwsRequestSigner.newBuilder(BOTOCORE_CREDENTIALS, "POST", url, "us-east-1") + .setAdditionalHeaders(headers) + .build(); + + AwsRequestSignature signature = signer.sign(); + + String expectedSignature = "debf546796015d6f6ded8626f5ce98597c33b47b9164cf6b17b4642036fcb592"; + String expectedAuthHeader = + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/" + + "aws4_request, SignedHeaders=date;host;p, Signature=" + + expectedSignature; + + assertEquals(expectedSignature, signature.getSignature()); + assertEquals(expectedAuthHeader, signature.getAuthorizationHeader()); + assertEquals(BOTOCORE_CREDENTIALS, signature.getSecurityCredentials()); + assertEquals(DATE, signature.getDate()); + assertEquals("POST", signature.getHttpMethod()); + assertEquals("us-east-1", signature.getRegion()); + assertEquals(URI.create(url).normalize().toString(), signature.getUrl()); + assertEquals(headerValue, signature.getCanonicalHeaders().get(headerKey.toLowerCase())); + } + + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/post-x-www-form-urlencoded.req + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/post-x-www-form-urlencoded.sreq + @Test + public void sign_postWithBodyNoCustomHeaders() { + String url = "https://host.foo.com/"; + String headerKey = "Content-Type"; + String headerValue = "application/x-www-form-urlencoded"; + + Map headers = new HashMap<>(); + headers.put("date", DATE); + headers.put(headerKey, headerValue); + + AwsRequestSigner signer = + AwsRequestSigner.newBuilder(BOTOCORE_CREDENTIALS, "POST", url, "us-east-1") + .setAdditionalHeaders(headers) + .setRequestPayload("foo=bar") + .build(); + + AwsRequestSignature signature = signer.sign(); + + String expectedSignature = "5a15b22cf462f047318703b92e6f4f38884e4a7ab7b1d6426ca46a8bd1c26cbc"; + String expectedAuthHeader = + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/" + + "aws4_request, SignedHeaders=content-type;date;host, Signature=" + + expectedSignature; + + assertEquals(expectedSignature, signature.getSignature()); + assertEquals(expectedAuthHeader, signature.getAuthorizationHeader()); + assertEquals(BOTOCORE_CREDENTIALS, signature.getSecurityCredentials()); + assertEquals(DATE, signature.getDate()); + assertEquals("POST", signature.getHttpMethod()); + assertEquals("us-east-1", signature.getRegion()); + assertEquals(URI.create(url).normalize().toString(), signature.getUrl()); + assertEquals(headerValue, signature.getCanonicalHeaders().get(headerKey.toLowerCase())); + } + + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/post-vanilla-query.req + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/post-vanilla-query.sreq + @Test + public void sign_postWithQueryString() { + String url = "https://host.foo.com/?foo=bar"; + + Map headers = new HashMap<>(); + headers.put("date", DATE); + + AwsRequestSigner signer = + AwsRequestSigner.newBuilder(BOTOCORE_CREDENTIALS, "POST", url, "us-east-1") + .setAdditionalHeaders(headers) + .build(); + + AwsRequestSignature signature = signer.sign(); + + String expectedSignature = "b6e3b79003ce0743a491606ba1035a804593b0efb1e20a11cba83f8c25a57a92"; + String expectedAuthHeader = + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/" + + "aws4_request, SignedHeaders=date;host, Signature=" + + expectedSignature; + + assertEquals(expectedSignature, signature.getSignature()); + assertEquals(expectedAuthHeader, signature.getAuthorizationHeader()); + assertEquals(BOTOCORE_CREDENTIALS, signature.getSecurityCredentials()); + assertEquals(DATE, signature.getDate()); + assertEquals("POST", signature.getHttpMethod()); + assertEquals("us-east-1", signature.getRegion()); + assertEquals(URI.create(url).normalize().toString(), signature.getUrl()); + } + + @Test + public void sign_getDescribeRegions() { + String url = "https://ec2.us-east-2.amazonaws.com?Action=DescribeRegions&Version=2013-10-15"; + + Map additionalHeaders = new HashMap<>(); + additionalHeaders.put("x-amz-date", X_AMZ_DATE); + + AwsRequestSigner signer = + AwsRequestSigner.newBuilder(awsSecurityCredentials, "GET", url, "us-east-2") + .setAdditionalHeaders(additionalHeaders) + .build(); + + AwsRequestSignature signature = signer.sign(); + + String expectedSignature = "631ea80cddfaa545fdadb120dc92c9f18166e38a5c47b50fab9fce476e022855"; + String expectedAuthHeader = + "AWS4-HMAC-SHA256 Credential=" + + awsSecurityCredentials.getAccessKeyId() + + "/20200811/us-east-2/ec2/" + + "aws4_request, SignedHeaders=host;x-amz-date;x-amz-security-token, Signature=" + + expectedSignature; + + assertEquals(expectedSignature, signature.getSignature()); + assertEquals(expectedAuthHeader, signature.getAuthorizationHeader()); + assertEquals(awsSecurityCredentials, signature.getSecurityCredentials()); + assertEquals(X_AMZ_DATE, signature.getDate()); + assertEquals("GET", signature.getHttpMethod()); + assertEquals("us-east-2", signature.getRegion()); + assertEquals(URI.create(url).normalize().toString(), signature.getUrl()); + } + + @Test + public void sign_postGetCallerIdentity() { + String url = "https://sts.us-east-2.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15"; + + Map additionalHeaders = new HashMap<>(); + additionalHeaders.put("x-amz-date", X_AMZ_DATE); + + AwsRequestSigner signer = + AwsRequestSigner.newBuilder(awsSecurityCredentials, "POST", url, "us-east-2") + .setAdditionalHeaders(additionalHeaders) + .build(); + + AwsRequestSignature signature = signer.sign(); + + String expectedSignature = "73452984e4a880ffdc5c392355733ec3f5ba310d5e0609a89244440cadfe7a7a"; + String expectedAuthHeader = + "AWS4-HMAC-SHA256 Credential=" + + awsSecurityCredentials.getAccessKeyId() + + "/20200811/us-east-2/sts/" + + "aws4_request, SignedHeaders=host;x-amz-date;x-amz-security-token, Signature=" + + expectedSignature; + + assertEquals(expectedSignature, signature.getSignature()); + assertEquals(expectedAuthHeader, signature.getAuthorizationHeader()); + assertEquals(awsSecurityCredentials, signature.getSecurityCredentials()); + assertEquals(X_AMZ_DATE, signature.getDate()); + assertEquals("POST", signature.getHttpMethod()); + assertEquals("us-east-2", signature.getRegion()); + assertEquals(URI.create(url).normalize().toString(), signature.getUrl()); + } + + @Test + public void sign_postGetCallerIdentityNoToken() { + String url = "https://sts.us-east-2.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15"; + + AwsSecurityCredentials awsSecurityCredentialsWithoutToken = + new AwsSecurityCredentials( + awsSecurityCredentials.getAccessKeyId(), + awsSecurityCredentials.getSecretAccessKey(), + /* token= */ null); + + Map additionalHeaders = new HashMap<>(); + additionalHeaders.put("x-amz-date", X_AMZ_DATE); + + AwsRequestSigner signer = + AwsRequestSigner.newBuilder(awsSecurityCredentialsWithoutToken, "POST", url, "us-east-2") + .setAdditionalHeaders(additionalHeaders) + .build(); + + AwsRequestSignature signature = signer.sign(); + + String expectedSignature = "d095ba304919cd0d5570ba8a3787884ee78b860f268ed040ba23831d55536d56"; + String expectedAuthHeader = + "AWS4-HMAC-SHA256 Credential=" + + awsSecurityCredentials.getAccessKeyId() + + "/20200811/us-east-2/sts/" + + "aws4_request, SignedHeaders=host;x-amz-date, Signature=" + + expectedSignature; + + assertEquals(expectedSignature, signature.getSignature()); + assertEquals(expectedAuthHeader, signature.getAuthorizationHeader()); + assertEquals(awsSecurityCredentialsWithoutToken, signature.getSecurityCredentials()); + assertEquals(X_AMZ_DATE, signature.getDate()); + assertEquals("POST", signature.getHttpMethod()); + assertEquals("us-east-2", signature.getRegion()); + assertEquals(URI.create(url).normalize().toString(), signature.getUrl()); + } + + public AwsSecurityCredentials retrieveAwsSecurityCredentials() throws IOException { + InputStream stream = + AwsRequestSignerTest.class + .getClassLoader() + .getResourceAsStream("aws_security_credentials.json"); + + JsonFactory jsonFactory = OAuth2Utils.JSON_FACTORY; + JsonObjectParser parser = new JsonObjectParser(jsonFactory); + + GenericJson json = parser.parseAndClose(stream, StandardCharsets.UTF_8, GenericJson.class); + + String awsToken = (String) json.get("Token"); + String secretAccessKey = (String) json.get("SecretAccessKey"); + String accessKeyId = (String) json.get("AccessKeyId"); + + return new AwsSecurityCredentials(accessKeyId, secretAccessKey, awsToken); + } +} diff --git a/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java new file mode 100644 index 000000000..a9d3ce49b --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java @@ -0,0 +1,358 @@ +/* + * Copyright 2021 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.google.api.client.http.HttpTransport; +import com.google.api.client.json.GenericJson; +import com.google.auth.TestUtils; +import com.google.auth.http.HttpTransportFactory; +import com.google.auth.oauth2.ExternalAccountCredentialsTest.TestExternalAccountCredentials.TestCredentialSource; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URI; +import java.util.Collection; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.annotation.Nullable; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Tests for {@link ExternalAccountCredentials}. */ +@RunWith(JUnit4.class) +public class ExternalAccountCredentialsTest { + + private static final String STS_URL = "https://www.sts.google.com"; + + static class MockExternalAccountCredentialsTransportFactory implements HttpTransportFactory { + + MockExternalAccountCredentialsTransport transport = + new MockExternalAccountCredentialsTransport(); + + @Override + public HttpTransport create() { + return transport; + } + } + + private MockExternalAccountCredentialsTransportFactory transportFactory; + + @Before + public void setup() { + transportFactory = new MockExternalAccountCredentialsTransportFactory(); + } + + @Test + public void fromStream_identityPoolCredentials() throws IOException { + GenericJson json = buildJsonIdentityPoolCredential(); + + ExternalAccountCredentials credential = + ExternalAccountCredentials.fromStream(TestUtils.jsonToInputStream(json)); + + assertTrue(credential instanceof IdentityPoolCredentials); + } + + @Test + public void fromStream_awsCredentials() throws IOException { + GenericJson json = buildJsonAwsCredential(); + + ExternalAccountCredentials credential = + ExternalAccountCredentials.fromStream(TestUtils.jsonToInputStream(json)); + + assertTrue(credential instanceof AwsCredentials); + } + + @Test + public void fromStream_invalidStream_throws() throws IOException { + GenericJson json = buildJsonAwsCredential(); + + json.put("audience", new HashMap<>()); + + try { + ExternalAccountCredentials.fromStream(TestUtils.jsonToInputStream(json)); + fail("Should fail."); + } catch (CredentialFormatException e) { + assertEquals("An invalid input stream was provided.", e.getMessage()); + } + } + + @Test + public void fromStream_nullTransport_throws() throws IOException { + try { + ExternalAccountCredentials.fromStream( + new ByteArrayInputStream("foo".getBytes()), /* transportFactory= */ null); + fail("NullPointerException should be thrown."); + } catch (NullPointerException e) { + // Expected. + } + } + + @Test + public void fromStream_nullStream_throws() throws IOException { + try { + ExternalAccountCredentials.fromStream( + /* credentialsStream= */ null, OAuth2Utils.HTTP_TRANSPORT_FACTORY); + fail("NullPointerException should be thrown."); + } catch (NullPointerException e) { + // Expected. + } + } + + @Test + public void fromJson_identityPoolCredentials() { + ExternalAccountCredentials credential = + ExternalAccountCredentials.fromJson( + buildJsonIdentityPoolCredential(), OAuth2Utils.HTTP_TRANSPORT_FACTORY); + + assertTrue(credential instanceof IdentityPoolCredentials); + assertEquals("audience", credential.getAudience()); + assertEquals("subjectTokenType", credential.getSubjectTokenType()); + assertEquals(STS_URL, credential.getTokenUrl()); + assertEquals("tokenInfoUrl", credential.getTokenInfoUrl()); + assertNotNull(credential.getCredentialSource()); + } + + @Test + public void fromJson_awsCredentials() { + ExternalAccountCredentials credential = + ExternalAccountCredentials.fromJson( + buildJsonAwsCredential(), OAuth2Utils.HTTP_TRANSPORT_FACTORY); + + assertTrue(credential instanceof AwsCredentials); + assertEquals("audience", credential.getAudience()); + assertEquals("subjectTokenType", credential.getSubjectTokenType()); + assertEquals(STS_URL, credential.getTokenUrl()); + assertEquals("tokenInfoUrl", credential.getTokenInfoUrl()); + assertNotNull(credential.getCredentialSource()); + } + + @Test + public void fromJson_nullJson_throws() { + try { + ExternalAccountCredentials.fromJson(/* json= */ null, OAuth2Utils.HTTP_TRANSPORT_FACTORY); + fail("Exception should be thrown."); + } catch (NullPointerException e) { + // Expected. + } + } + + @Test + public void fromJson_invalidServiceAccountImpersonationUrl_throws() { + GenericJson json = buildJsonIdentityPoolCredential(); + json.put("service_account_impersonation_url", "invalid_url"); + + try { + ExternalAccountCredentials.fromJson(json, OAuth2Utils.HTTP_TRANSPORT_FACTORY); + fail("Exception should be thrown."); + } catch (IllegalArgumentException e) { + assertEquals( + "Unable to determine target principal from service account impersonation URL.", + e.getMessage()); + } + } + + @Test + public void fromJson_nullTransport_throws() { + try { + ExternalAccountCredentials.fromJson( + new HashMap(), /* transportFactory= */ null); + fail("Exception should be thrown."); + } catch (NullPointerException e) { + // Expected. + } + } + + @Test + public void exchangeExternalCredentialForAccessToken() throws IOException { + ExternalAccountCredentials credential = + ExternalAccountCredentials.fromJson(buildJsonIdentityPoolCredential(), transportFactory); + + StsTokenExchangeRequest stsTokenExchangeRequest = + StsTokenExchangeRequest.newBuilder("credential", "subjectTokenType").build(); + + AccessToken accessToken = + credential.exchangeExternalCredentialForAccessToken(stsTokenExchangeRequest); + + assertEquals(transportFactory.transport.getAccessToken(), accessToken.getTokenValue()); + } + + @Test + public void exchangeExternalCredentialForAccessToken_withServiceAccountImpersonation() + throws IOException { + transportFactory.transport.setExpireTime(TestUtils.getDefaultExpireTime()); + + ExternalAccountCredentials credential = + ExternalAccountCredentials.fromStream( + IdentityPoolCredentialsTest.writeIdentityPoolCredentialsStream( + transportFactory.transport.getStsUrl(), + transportFactory.transport.getMetadataUrl(), + transportFactory.transport.getServiceAccountImpersonationUrl()), + transportFactory); + + StsTokenExchangeRequest stsTokenExchangeRequest = + StsTokenExchangeRequest.newBuilder("credential", "subjectTokenType").build(); + + AccessToken returnedToken = + credential.exchangeExternalCredentialForAccessToken(stsTokenExchangeRequest); + + assertEquals( + transportFactory.transport.getServiceAccountAccessToken(), returnedToken.getTokenValue()); + } + + @Test + public void exchangeExternalCredentialForAccessToken_throws() throws IOException { + ExternalAccountCredentials credential = + ExternalAccountCredentials.fromJson(buildJsonIdentityPoolCredential(), transportFactory); + + String errorCode = "invalidRequest"; + String errorDescription = "errorDescription"; + String errorUri = "errorUri"; + transportFactory.transport.addResponseErrorSequence( + TestUtils.buildHttpResponseException(errorCode, errorDescription, errorUri)); + + StsTokenExchangeRequest stsTokenExchangeRequest = + StsTokenExchangeRequest.newBuilder("credential", "subjectTokenType").build(); + + try { + credential.exchangeExternalCredentialForAccessToken(stsTokenExchangeRequest); + fail("Exception should be thrown."); + } catch (OAuthException e) { + assertEquals(errorCode, e.getErrorCode()); + assertEquals(errorDescription, e.getErrorDescription()); + assertEquals(errorUri, e.getErrorUri()); + } + } + + @Test + public void getRequestMetadata_withQuotaProjectId() throws IOException { + TestExternalAccountCredentials testCredentials = + new TestExternalAccountCredentials( + transportFactory, + "audience", + "subjectTokenType", + "tokenUrl", + "tokenInfoUrl", + new TestCredentialSource(new HashMap()), + /* serviceAccountImpersonationUrl= */ null, + "quotaProjectId", + /* clientId= */ null, + /* clientSecret= */ null, + /* scopes= */ null); + + Map> requestMetadata = + testCredentials.getRequestMetadata(URI.create("http://googleapis.com/foo/bar")); + + assertEquals("quotaProjectId", requestMetadata.get("x-goog-user-project").get(0)); + } + + private GenericJson buildJsonIdentityPoolCredential() { + GenericJson json = new GenericJson(); + json.put("audience", "audience"); + json.put("subject_token_type", "subjectTokenType"); + json.put("token_url", STS_URL); + json.put("token_info_url", "tokenInfoUrl"); + + Map map = new HashMap<>(); + map.put("file", "file"); + json.put("credential_source", map); + return json; + } + + private GenericJson buildJsonAwsCredential() { + GenericJson json = new GenericJson(); + json.put("audience", "audience"); + json.put("subject_token_type", "subjectTokenType"); + json.put("token_url", STS_URL); + json.put("token_info_url", "tokenInfoUrl"); + + Map map = new HashMap<>(); + map.put("environment_id", "aws1"); + map.put("region_url", "regionUrl"); + map.put("url", "url"); + map.put("regional_cred_verification_url", "regionalCredVerificationUrl"); + json.put("credential_source", map); + + return json; + } + + static class TestExternalAccountCredentials extends ExternalAccountCredentials { + static class TestCredentialSource extends ExternalAccountCredentials.CredentialSource { + protected TestCredentialSource(Map credentialSourceMap) { + super(credentialSourceMap); + } + } + + protected TestExternalAccountCredentials( + HttpTransportFactory transportFactory, + String audience, + String subjectTokenType, + String tokenUrl, + String tokenInfoUrl, + CredentialSource credentialSource, + @Nullable String serviceAccountImpersonationUrl, + @Nullable String quotaProjectId, + @Nullable String clientId, + @Nullable String clientSecret, + @Nullable Collection scopes) { + super( + transportFactory, + audience, + subjectTokenType, + tokenUrl, + credentialSource, + tokenInfoUrl, + serviceAccountImpersonationUrl, + quotaProjectId, + clientId, + clientSecret, + scopes); + } + + @Override + public AccessToken refreshAccessToken() { + return new AccessToken("accessToken", new Date()); + } + + @Override + public String retrieveSubjectToken() { + return "subjectToken"; + } + } +} diff --git a/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java index ebf60a8ce..6af637284 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java @@ -40,6 +40,7 @@ import com.google.api.client.testing.http.MockHttpTransport; import com.google.auth.TestUtils; import com.google.auth.http.HttpTransportFactory; +import com.google.auth.oauth2.IdentityPoolCredentialsTest.MockExternalAccountCredentialsTransportFactory; import com.google.common.collect.ImmutableList; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -224,6 +225,45 @@ public void fromStream_userNoRefreshToken_throws() throws IOException { testFromStreamException(userStream, "refresh_token"); } + @Test + public void fromStream_identityPoolCredentials_providesToken() throws IOException { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + InputStream identityPoolCredentialStream = + IdentityPoolCredentialsTest.writeIdentityPoolCredentialsStream( + transportFactory.transport.getStsUrl(), + transportFactory.transport.getMetadataUrl(), + /* serviceAccountImpersonationUrl= */ null); + + GoogleCredentials credentials = + GoogleCredentials.fromStream(identityPoolCredentialStream, transportFactory); + + assertNotNull(credentials); + credentials = credentials.createScoped(SCOPES); + Map> metadata = credentials.getRequestMetadata(CALL_URI); + TestUtils.assertContainsBearerToken(metadata, transportFactory.transport.getAccessToken()); + } + + @Test + public void fromStream_awsCredentials_providesToken() throws IOException { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + InputStream awsCredentialStream = + AwsCredentialsTest.writeAwsCredentialsStream( + transportFactory.transport.getStsUrl(), + transportFactory.transport.getAwsRegionUrl(), + transportFactory.transport.getAwsCredentialsUrl()); + + GoogleCredentials credentials = + GoogleCredentials.fromStream(awsCredentialStream, transportFactory); + + assertNotNull(credentials); + credentials = credentials.createScoped(SCOPES); + Map> metadata = credentials.getRequestMetadata(CALL_URI); + TestUtils.assertContainsBearerToken(metadata, transportFactory.transport.getAccessToken()); + } + @Test public void createScoped_overloadCallsImplementation() { final AtomicReference> called = new AtomicReference<>(); diff --git a/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java new file mode 100644 index 000000000..4095edcad --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -0,0 +1,488 @@ +/* + * Copyright 2021 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import static com.google.auth.oauth2.MockExternalAccountCredentialsTransport.SERVICE_ACCOUNT_IMPERSONATION_URL; +import static com.google.auth.oauth2.OAuth2Utils.JSON_FACTORY; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +import com.google.api.client.http.HttpTransport; +import com.google.api.client.json.GenericJson; +import com.google.auth.TestUtils; +import com.google.auth.http.HttpTransportFactory; +import com.google.auth.oauth2.IdentityPoolCredentials.IdentityPoolCredentialSource; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.annotation.Nullable; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Tests for {@link IdentityPoolCredentials}. */ +@RunWith(JUnit4.class) +public class IdentityPoolCredentialsTest { + + private static final Map FILE_CREDENTIAL_SOURCE_MAP = + new HashMap() { + { + put("file", "file"); + } + }; + + private static final IdentityPoolCredentialSource FILE_CREDENTIAL_SOURCE = + new IdentityPoolCredentialSource(FILE_CREDENTIAL_SOURCE_MAP); + + private static final IdentityPoolCredentials FILE_SOURCED_CREDENTIAL = + (IdentityPoolCredentials) + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("tokenUrl") + .setTokenInfoUrl("tokenInfoUrl") + .setCredentialSource(FILE_CREDENTIAL_SOURCE) + .build(); + + static class MockExternalAccountCredentialsTransportFactory implements HttpTransportFactory { + + MockExternalAccountCredentialsTransport transport = + new MockExternalAccountCredentialsTransport(); + + @Override + public HttpTransport create() { + return transport; + } + } + + @Test + public void createdScoped_clonedCredentialWithAddedScopes() { + IdentityPoolCredentials credentials = + (IdentityPoolCredentials) + IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL) + .setQuotaProjectId("quotaProjectId") + .setClientId("clientId") + .setClientSecret("clientSecret") + .build(); + + List newScopes = Arrays.asList("scope1", "scope2"); + + IdentityPoolCredentials newCredentials = + (IdentityPoolCredentials) credentials.createScoped(newScopes); + + assertEquals(credentials.getAudience(), newCredentials.getAudience()); + assertEquals(credentials.getSubjectTokenType(), newCredentials.getSubjectTokenType()); + assertEquals(credentials.getTokenUrl(), newCredentials.getTokenUrl()); + assertEquals(credentials.getTokenInfoUrl(), newCredentials.getTokenInfoUrl()); + assertEquals( + credentials.getServiceAccountImpersonationUrl(), + newCredentials.getServiceAccountImpersonationUrl()); + assertEquals(credentials.getCredentialSource(), newCredentials.getCredentialSource()); + assertEquals(newScopes, newCredentials.getScopes()); + assertEquals(credentials.getQuotaProjectId(), newCredentials.getQuotaProjectId()); + assertEquals(credentials.getClientId(), newCredentials.getClientId()); + assertEquals(credentials.getClientSecret(), newCredentials.getClientSecret()); + } + + @Test + public void retrieveSubjectToken_fileSourced() throws IOException { + File file = + File.createTempFile("RETRIEVE_SUBJECT_TOKEN", /* suffix= */ null, /* directory= */ null); + file.deleteOnExit(); + + String credential = "credential"; + OAuth2Utils.writeInputStreamToFile( + new ByteArrayInputStream(credential.getBytes(StandardCharsets.UTF_8)), + file.getAbsolutePath()); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", file.getAbsolutePath()); + IdentityPoolCredentialSource credentialSource = + new IdentityPoolCredentialSource(credentialSourceMap); + + IdentityPoolCredentials credentials = + (IdentityPoolCredentials) + IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + .setCredentialSource(credentialSource) + .build(); + + String subjectToken = credentials.retrieveSubjectToken(); + + assertEquals(credential, subjectToken); + } + + @Test + public void retrieveSubjectToken_fileSourcedWithJsonFormat() throws IOException { + File file = + File.createTempFile("RETRIEVE_SUBJECT_TOKEN", /* suffix= */ null, /* directory= */ null); + file.deleteOnExit(); + + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + transportFactory.transport.setMetadataServerContentType("json"); + + Map credentialSourceMap = new HashMap<>(); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "subjectToken"); + + credentialSourceMap.put("file", file.getAbsolutePath()); + credentialSourceMap.put("format", formatMap); + + IdentityPoolCredentialSource credentialSource = + new IdentityPoolCredentialSource(credentialSourceMap); + + GenericJson response = new GenericJson(); + response.setFactory(JSON_FACTORY); + response.put("subjectToken", "subjectToken"); + + OAuth2Utils.writeInputStreamToFile( + new ByteArrayInputStream(response.toString().getBytes(StandardCharsets.UTF_8)), + file.getAbsolutePath()); + + IdentityPoolCredentials credential = + (IdentityPoolCredentials) + IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + .setHttpTransportFactory(transportFactory) + .setCredentialSource(credentialSource) + .build(); + + String subjectToken = credential.retrieveSubjectToken(); + + assertEquals("subjectToken", subjectToken); + } + + @Test + public void retrieveSubjectToken_fileSourcedWithNullFormat_throws() throws IOException { + File file = + File.createTempFile("RETRIEVE_SUBJECT_TOKEN", /* suffix= */ null, /* directory= */ null); + file.deleteOnExit(); + + Map credentialSourceMap = new HashMap<>(); + Map formatMap = new HashMap<>(); + formatMap.put("type", null); + + credentialSourceMap.put("file", file.getAbsolutePath()); + credentialSourceMap.put("format", formatMap); + + try { + new IdentityPoolCredentialSource(credentialSourceMap); + fail("Exception should be thrown due to null format."); + } catch (IllegalArgumentException e) { + assertEquals("Invalid credential source format type: null.", e.getMessage()); + } + } + + @Test + public void retrieveSubjectToken_noFile_throws() { + Map credentialSourceMap = new HashMap<>(); + String path = "badPath"; + credentialSourceMap.put("file", path); + IdentityPoolCredentialSource credentialSource = + new IdentityPoolCredentialSource(credentialSourceMap); + + IdentityPoolCredentials credentials = + (IdentityPoolCredentials) + IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + .setCredentialSource(credentialSource) + .build(); + + try { + credentials.retrieveSubjectToken(); + fail("Exception should be thrown."); + } catch (IOException e) { + assertEquals( + String.format("Invalid credential location. The file at %s does not exist.", path), + e.getMessage()); + } + } + + @Test + public void retrieveSubjectToken_urlSourced() throws IOException { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + IdentityPoolCredentials credential = + (IdentityPoolCredentials) + IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + .setHttpTransportFactory(transportFactory) + .setCredentialSource( + buildUrlBasedCredentialSource(transportFactory.transport.getMetadataUrl())) + .build(); + + String subjectToken = credential.retrieveSubjectToken(); + + assertEquals(transportFactory.transport.getSubjectToken(), subjectToken); + } + + @Test + public void retrieveSubjectToken_urlSourcedWithJsonFormat() throws IOException { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + transportFactory.transport.setMetadataServerContentType("json"); + + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "subjectToken"); + + IdentityPoolCredentialSource credentialSource = + buildUrlBasedCredentialSource(transportFactory.transport.getMetadataUrl(), formatMap); + + IdentityPoolCredentials credential = + (IdentityPoolCredentials) + IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + .setHttpTransportFactory(transportFactory) + .setCredentialSource(credentialSource) + .build(); + + String subjectToken = credential.retrieveSubjectToken(); + + assertEquals(transportFactory.transport.getSubjectToken(), subjectToken); + } + + @Test + public void retrieveSubjectToken_urlSourcedCredential_throws() { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + IOException response = new IOException(); + transportFactory.transport.addResponseErrorSequence(response); + + IdentityPoolCredentials credential = + (IdentityPoolCredentials) + IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + .setHttpTransportFactory(transportFactory) + .setCredentialSource( + buildUrlBasedCredentialSource(transportFactory.transport.getMetadataUrl())) + .build(); + + try { + credential.retrieveSubjectToken(); + fail("Exception should be thrown."); + } catch (IOException e) { + assertEquals( + String.format( + "Error getting subject token from metadata server: %s", response.getMessage()), + e.getMessage()); + } + } + + @Test + public void refreshAccessToken_withoutServiceAccountImpersonation() throws IOException { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + IdentityPoolCredentials credential = + (IdentityPoolCredentials) + IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + .setTokenUrl(transportFactory.transport.getStsUrl()) + .setHttpTransportFactory(transportFactory) + .setCredentialSource( + buildUrlBasedCredentialSource(transportFactory.transport.getMetadataUrl())) + .build(); + + AccessToken accessToken = credential.refreshAccessToken(); + + assertEquals(transportFactory.transport.getAccessToken(), accessToken.getTokenValue()); + } + + @Test + public void refreshAccessToken_withServiceAccountImpersonation() throws IOException { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + transportFactory.transport.setExpireTime(TestUtils.getDefaultExpireTime()); + IdentityPoolCredentials credential = + (IdentityPoolCredentials) + IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + .setTokenUrl(transportFactory.transport.getStsUrl()) + .setServiceAccountImpersonationUrl( + transportFactory.transport.getServiceAccountImpersonationUrl()) + .setHttpTransportFactory(transportFactory) + .setCredentialSource( + buildUrlBasedCredentialSource(transportFactory.transport.getMetadataUrl())) + .build(); + + AccessToken accessToken = credential.refreshAccessToken(); + + assertEquals( + transportFactory.transport.getServiceAccountAccessToken(), accessToken.getTokenValue()); + } + + @Test + public void identityPoolCredentialSource_invalidSourceType() { + try { + new IdentityPoolCredentialSource(new HashMap()); + fail("Exception should be thrown."); + } catch (IllegalArgumentException e) { + assertEquals( + "Missing credential source file location or URL. At least one must be specified.", + e.getMessage()); + } + } + + @Test + public void identityPoolCredentialSource_invalidFormatType() { + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("url", "url"); + + Map format = new HashMap<>(); + format.put("type", "unsupportedType"); + credentialSourceMap.put("format", format); + + try { + new IdentityPoolCredentialSource(credentialSourceMap); + fail("Exception should be thrown."); + } catch (IllegalArgumentException e) { + assertEquals("Invalid credential source format type: unsupportedType.", e.getMessage()); + } + } + + @Test + public void identityPoolCredentialSource_nullFormatType() { + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("url", "url"); + + Map format = new HashMap<>(); + format.put("type", null); + credentialSourceMap.put("format", format); + + try { + new IdentityPoolCredentialSource(credentialSourceMap); + fail("Exception should be thrown."); + } catch (IllegalArgumentException e) { + assertEquals("Invalid credential source format type: null.", e.getMessage()); + } + } + + @Test + public void identityPoolCredentialSource_subjectTokenFieldNameUnset() { + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("url", "url"); + + Map format = new HashMap<>(); + format.put("type", "json"); + credentialSourceMap.put("format", format); + + try { + new IdentityPoolCredentialSource(credentialSourceMap); + fail("Exception should be thrown."); + } catch (IllegalArgumentException e) { + assertEquals( + "When specifying a JSON credential type, the subject_token_field_name must be set.", + e.getMessage()); + } + } + + @Test + public void builder() { + List scopes = Arrays.asList("scope1", "scope2"); + + IdentityPoolCredentials credentials = + (IdentityPoolCredentials) + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("tokenUrl") + .setCredentialSource(FILE_CREDENTIAL_SOURCE) + .setTokenInfoUrl("tokenInfoUrl") + .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL) + .setQuotaProjectId("quotaProjectId") + .setClientId("clientId") + .setClientSecret("clientSecret") + .setScopes(scopes) + .build(); + + assertEquals("audience", credentials.getAudience()); + assertEquals("subjectTokenType", credentials.getSubjectTokenType()); + assertEquals(credentials.getTokenUrl(), "tokenUrl"); + assertEquals(credentials.getTokenInfoUrl(), "tokenInfoUrl"); + assertEquals( + credentials.getServiceAccountImpersonationUrl(), SERVICE_ACCOUNT_IMPERSONATION_URL); + assertEquals(credentials.getCredentialSource(), FILE_CREDENTIAL_SOURCE); + assertEquals(credentials.getQuotaProjectId(), "quotaProjectId"); + assertEquals(credentials.getClientId(), "clientId"); + assertEquals(credentials.getClientSecret(), "clientSecret"); + assertEquals(credentials.getScopes(), scopes); + } + + static InputStream writeIdentityPoolCredentialsStream( + String tokenUrl, String url, @Nullable String serviceAccountImpersonationUrl) + throws IOException { + GenericJson json = new GenericJson(); + json.put("audience", "audience"); + json.put("subject_token_type", "subjectTokenType"); + json.put("token_url", tokenUrl); + json.put("token_info_url", "tokenInfoUrl"); + json.put("type", ExternalAccountCredentials.EXTERNAL_ACCOUNT_FILE_TYPE); + + if (serviceAccountImpersonationUrl != null) { + json.put("service_account_impersonation_url", serviceAccountImpersonationUrl); + } + + GenericJson credentialSource = new GenericJson(); + GenericJson headers = new GenericJson(); + headers.put("Metadata-Flavor", "Google"); + credentialSource.put("url", url); + credentialSource.put("headers", headers); + + json.put("credential_source", credentialSource); + return TestUtils.jsonToInputStream(json); + } + + private static IdentityPoolCredentialSource buildUrlBasedCredentialSource(String url) { + return buildUrlBasedCredentialSource(url, /* formatMap= */ null); + } + + private static IdentityPoolCredentialSource buildUrlBasedCredentialSource( + String url, Map formatMap) { + Map credentialSourceMap = new HashMap<>(); + Map headers = new HashMap<>(); + headers.put("Metadata-Flavor", "Google"); + credentialSourceMap.put("url", url); + credentialSourceMap.put("headers", headers); + credentialSourceMap.put("format", formatMap); + + return new IdentityPoolCredentialSource(credentialSourceMap); + } +} diff --git a/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java b/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java new file mode 100644 index 000000000..fc7e0cdb9 --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java @@ -0,0 +1,263 @@ +/* + * Copyright 2021 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import com.google.api.client.http.LowLevelHttpRequest; +import com.google.api.client.http.LowLevelHttpResponse; +import com.google.api.client.json.GenericJson; +import com.google.api.client.json.Json; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.gson.GsonFactory; +import com.google.api.client.testing.http.MockHttpTransport; +import com.google.api.client.testing.http.MockLowLevelHttpRequest; +import com.google.api.client.testing.http.MockLowLevelHttpResponse; +import com.google.auth.TestUtils; +import com.google.common.base.Joiner; +import java.io.IOException; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Queue; + +/** + * Mock transport that handles the necessary steps to exchange an external credential for a GCP + * access-token. + */ +public class MockExternalAccountCredentialsTransport extends MockHttpTransport { + + private static final String EXPECTED_GRANT_TYPE = + "urn:ietf:params:oauth:grant-type:token-exchange"; + private static final String CLOUD_PLATFORM_SCOPE = + "https://www.googleapis.com/auth/cloud-platform"; + private static final String ISSUED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"; + private static final String AWS_CREDENTIALS_URL = "https://www.aws-credentials.com"; + private static final String AWS_REGION_URL = "https://www.aws-region.com"; + private static final String METADATA_SERVER_URL = "https://www.metadata.google.com"; + private static final String STS_URL = "https://www.sts.google.com"; + + private static final String SUBJECT_TOKEN = "subjectToken"; + private static final String TOKEN_TYPE = "Bearer"; + private static final String ACCESS_TOKEN = "accessToken"; + private static final String SERVICE_ACCOUNT_ACCESS_TOKEN = "serviceAccountAccessToken"; + private static final Long EXPIRES_IN = 3600L; + + private static final JsonFactory JSON_FACTORY = new GsonFactory(); + + static final String SERVICE_ACCOUNT_IMPERSONATION_URL = + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/testn@test.iam.gserviceaccount.com:generateAccessToken"; + + private Queue responseSequence = new ArrayDeque<>(); + private Queue responseErrorSequence = new ArrayDeque<>(); + private Queue refreshTokenSequence = new ArrayDeque<>(); + private Queue> scopeSequence = new ArrayDeque<>(); + private MockLowLevelHttpRequest request; + private String expireTime; + private String metadataServerContentType; + + public void addResponseErrorSequence(IOException... errors) { + Collections.addAll(responseErrorSequence, errors); + } + + public void addResponseSequence(Boolean... responses) { + Collections.addAll(responseSequence, responses); + } + + public void addRefreshTokenSequence(String... refreshTokens) { + Collections.addAll(refreshTokenSequence, refreshTokens); + } + + public void addScopeSequence(List... scopes) { + Collections.addAll(scopeSequence, scopes); + } + + @Override + public LowLevelHttpRequest buildRequest(final String method, final String url) { + this.request = + new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() throws IOException { + boolean successfulResponse = !responseSequence.isEmpty() && responseSequence.poll(); + + if (!responseErrorSequence.isEmpty() && !successfulResponse) { + throw responseErrorSequence.poll(); + } + + if (AWS_REGION_URL.equals(url)) { + return new MockLowLevelHttpResponse() + .setContentType("text/html") + .setContent("us-east-1b"); + } + if (AWS_CREDENTIALS_URL.equals(url)) { + return new MockLowLevelHttpResponse() + .setContentType("text/html") + .setContent("roleName"); + } + if ((AWS_CREDENTIALS_URL + "/" + "roleName").equals(url)) { + GenericJson response = new GenericJson(); + response.setFactory(JSON_FACTORY); + response.put("AccessKeyId", "accessKeyId"); + response.put("SecretAccessKey", "secretAccessKey"); + response.put("Token", "token"); + + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } + + if (METADATA_SERVER_URL.equals(url)) { + String metadataRequestHeader = getFirstHeaderValue("Metadata-Flavor"); + if (!"Google".equals(metadataRequestHeader)) { + throw new IOException("Metadata request header not found."); + } + + if (metadataServerContentType != null && metadataServerContentType.equals("json")) { + GenericJson response = new GenericJson(); + response.setFactory(JSON_FACTORY); + response.put("subjectToken", SUBJECT_TOKEN); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } + return new MockLowLevelHttpResponse() + .setContentType("text/html") + .setContent(SUBJECT_TOKEN); + } + if (STS_URL.equals(url)) { + Map query = TestUtils.parseQuery(getContentAsString()); + assertEquals(EXPECTED_GRANT_TYPE, query.get("grant_type")); + assertNotNull(query.get("subject_token_type")); + assertNotNull(query.get("subject_token")); + + GenericJson response = new GenericJson(); + response.setFactory(JSON_FACTORY); + response.put("token_type", TOKEN_TYPE); + response.put("expires_in", EXPIRES_IN); + response.put("access_token", ACCESS_TOKEN); + response.put("issued_token_type", ISSUED_TOKEN_TYPE); + + if (!refreshTokenSequence.isEmpty()) { + response.put("refresh_token", refreshTokenSequence.poll()); + } + if (!scopeSequence.isEmpty()) { + response.put("scope", Joiner.on(' ').join(scopeSequence.poll())); + } + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toPrettyString()); + } + if (SERVICE_ACCOUNT_IMPERSONATION_URL.equals(url)) { + GenericJson query = + OAuth2Utils.JSON_FACTORY + .createJsonParser(getContentAsString()) + .parseAndClose(GenericJson.class); + assertEquals(CLOUD_PLATFORM_SCOPE, ((ArrayList) query.get("scope")).get(0)); + assertEquals(1, getHeaders().get("authorization").size()); + assertTrue(getHeaders().containsKey("authorization")); + assertNotNull(getHeaders().get("authorization").get(0)); + + GenericJson response = new GenericJson(); + response.setFactory(JSON_FACTORY); + response.put("accessToken", SERVICE_ACCOUNT_ACCESS_TOKEN); + response.put("expireTime", expireTime); + + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toPrettyString()); + } + return null; + } + }; + return this.request; + } + + public MockLowLevelHttpRequest getRequest() { + return request; + } + + public String getTokenType() { + return TOKEN_TYPE; + } + + public String getAccessToken() { + return ACCESS_TOKEN; + } + + public String getServiceAccountAccessToken() { + return SERVICE_ACCOUNT_ACCESS_TOKEN; + } + + public String getIssuedTokenType() { + return ISSUED_TOKEN_TYPE; + } + + public Long getExpiresIn() { + return EXPIRES_IN; + } + + public String getSubjectToken() { + return SUBJECT_TOKEN; + } + + public String getMetadataUrl() { + return METADATA_SERVER_URL; + } + + public String getAwsCredentialsUrl() { + return AWS_CREDENTIALS_URL; + } + + public String getAwsRegionUrl() { + return AWS_REGION_URL; + } + + public String getStsUrl() { + return STS_URL; + } + + public String getServiceAccountImpersonationUrl() { + return SERVICE_ACCOUNT_IMPERSONATION_URL; + } + + public void setExpireTime(String expireTime) { + this.expireTime = expireTime; + } + + public void setMetadataServerContentType(String contentType) { + this.metadataServerContentType = contentType; + } +} diff --git a/oauth2_http/javatests/com/google/auth/oauth2/OAuthExceptionTest.java b/oauth2_http/javatests/com/google/auth/oauth2/OAuthExceptionTest.java new file mode 100644 index 000000000..f864f4791 --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/oauth2/OAuthExceptionTest.java @@ -0,0 +1,87 @@ +/* + * Copyright 2021 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Tests for {@link OAuthException}. */ +@RunWith(JUnit4.class) +public final class OAuthExceptionTest { + + private static final String FULL_MESSAGE_FORMAT = "Error code %s: %s - %s"; + private static final String ERROR_DESCRIPTION_FORMAT = "Error code %s: %s"; + private static final String BASE_MESSAGE_FORMAT = "Error code %s"; + + @Test + public void getMessage_fullFormat() { + OAuthException e = new OAuthException("errorCode", "errorDescription", "errorUri"); + + assertEquals("errorCode", e.getErrorCode()); + assertEquals("errorDescription", e.getErrorDescription()); + assertEquals("errorUri", e.getErrorUri()); + + String expectedMessage = + String.format(FULL_MESSAGE_FORMAT, "errorCode", "errorDescription", "errorUri"); + assertEquals(expectedMessage, e.getMessage()); + } + + @Test + public void getMessage_descriptionFormat() { + OAuthException e = new OAuthException("errorCode", "errorDescription", /* errorUri= */ null); + + assertEquals("errorCode", e.getErrorCode()); + assertEquals("errorDescription", e.getErrorDescription()); + assertNull(e.getErrorUri()); + + String expectedMessage = + String.format(ERROR_DESCRIPTION_FORMAT, "errorCode", "errorDescription"); + assertEquals(expectedMessage, e.getMessage()); + } + + @Test + public void getMessage_baseFormat() { + OAuthException e = + new OAuthException("errorCode", /* errorDescription= */ null, /* errorUri= */ null); + + assertEquals("errorCode", e.getErrorCode()); + assertNull(e.getErrorDescription()); + assertNull(e.getErrorUri()); + + String expectedMessage = String.format(BASE_MESSAGE_FORMAT, "errorCode"); + assertEquals(expectedMessage, e.getMessage()); + } +} diff --git a/oauth2_http/javatests/com/google/auth/oauth2/StsRequestHandlerTest.java b/oauth2_http/javatests/com/google/auth/oauth2/StsRequestHandlerTest.java new file mode 100644 index 000000000..65d2bf90f --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/oauth2/StsRequestHandlerTest.java @@ -0,0 +1,251 @@ +/* + * Copyright 2021 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; + +import com.google.api.client.http.HttpHeaders; +import com.google.api.client.testing.http.MockLowLevelHttpRequest; +import com.google.api.client.util.GenericData; +import com.google.auth.TestUtils; +import com.google.common.base.Joiner; +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; +import org.junit.function.ThrowingRunnable; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Tests for {@link StsRequestHandler}. */ +@RunWith(JUnit4.class) +public final class StsRequestHandlerTest { + + private static final String TOKEN_EXCHANGE_GRANT_TYPE = + "urn:ietf:params:oauth:grant-type:token-exchange"; + private static final String CLOUD_PLATFORM_SCOPE = + "https://www.googleapis.com/auth/cloud-platform"; + private static final String DEFAULT_REQUESTED_TOKEN_TYPE = + "urn:ietf:params:oauth:token-type:access_token"; + private static final String TOKEN_URL = "https://www.sts.google.com"; + + private MockExternalAccountCredentialsTransport transport; + + @Before + public void setup() { + transport = new MockExternalAccountCredentialsTransport(); + } + + @Test + public void exchangeToken() throws IOException { + StsTokenExchangeRequest stsTokenExchangeRequest = + StsTokenExchangeRequest.newBuilder("credential", "subjectTokenType") + .setScopes(Arrays.asList(CLOUD_PLATFORM_SCOPE)) + .build(); + + StsRequestHandler requestHandler = + StsRequestHandler.newBuilder( + TOKEN_URL, stsTokenExchangeRequest, transport.createRequestFactory()) + .build(); + + StsTokenExchangeResponse response = requestHandler.exchangeToken(); + + // Validate response. + assertEquals(transport.getAccessToken(), response.getAccessToken().getTokenValue()); + assertEquals(transport.getTokenType(), response.getTokenType()); + assertEquals(transport.getIssuedTokenType(), response.getIssuedTokenType()); + assertEquals(transport.getExpiresIn(), response.getExpiresInSeconds()); + + // Validate request content. + GenericData expectedRequestContent = + new GenericData() + .set("grant_type", TOKEN_EXCHANGE_GRANT_TYPE) + .set("scope", CLOUD_PLATFORM_SCOPE) + .set("requested_token_type", DEFAULT_REQUESTED_TOKEN_TYPE) + .set("subject_token_type", stsTokenExchangeRequest.getSubjectTokenType()) + .set("subject_token", stsTokenExchangeRequest.getSubjectToken()); + + MockLowLevelHttpRequest request = transport.getRequest(); + Map actualRequestContent = TestUtils.parseQuery(request.getContentAsString()); + assertEquals(expectedRequestContent.getUnknownKeys(), actualRequestContent); + } + + @Test + public void exchangeToken_withOptionalParams() throws IOException { + // Return optional params scope and the refresh_token. + transport.addScopeSequence(Arrays.asList("scope1", "scope2", "scope3")); + transport.addRefreshTokenSequence("refreshToken"); + + // Build the token exchange request. + StsTokenExchangeRequest stsTokenExchangeRequest = + StsTokenExchangeRequest.newBuilder("credential", "subjectTokenType") + .setAudience("audience") + .setResource("resource") + .setActingParty(new ActingParty("actorToken", "actorTokenType")) + .setRequestTokenType("requestedTokenType") + .setScopes(Arrays.asList("scope1", "scope2", "scope3")) + .build(); + + HttpHeaders httpHeaders = + new HttpHeaders() + .setContentType("application/x-www-form-urlencoded") + .setAcceptEncoding("gzip") + .set("custom_header_key", "custom_header_value"); + + StsRequestHandler requestHandler = + StsRequestHandler.newBuilder( + TOKEN_URL, stsTokenExchangeRequest, transport.createRequestFactory()) + .setHeaders(httpHeaders) + .setInternalOptions("internalOptions") + .build(); + + StsTokenExchangeResponse response = requestHandler.exchangeToken(); + + // Validate response. + assertEquals(transport.getAccessToken(), response.getAccessToken().getTokenValue()); + assertEquals(transport.getTokenType(), response.getTokenType()); + assertEquals(transport.getIssuedTokenType(), response.getIssuedTokenType()); + assertEquals(transport.getExpiresIn(), response.getExpiresInSeconds()); + assertEquals(Arrays.asList("scope1", "scope2", "scope3"), response.getScopes()); + assertEquals("refreshToken", response.getRefreshToken()); + + // Validate headers. + MockLowLevelHttpRequest request = transport.getRequest(); + Map> requestHeaders = request.getHeaders(); + assertEquals("application/x-www-form-urlencoded", requestHeaders.get("content-type").get(0)); + assertEquals("gzip", requestHeaders.get("accept-encoding").get(0)); + assertEquals("custom_header_value", requestHeaders.get("custom_header_key").get(0)); + + // Validate request content. + GenericData expectedRequestContent = + new GenericData() + .set("grant_type", TOKEN_EXCHANGE_GRANT_TYPE) + .set("scope", Joiner.on(' ').join(Arrays.asList("scope1", "scope2", "scope3"))) + .set("options", "internalOptions") + .set("subject_token_type", stsTokenExchangeRequest.getSubjectTokenType()) + .set("subject_token", stsTokenExchangeRequest.getSubjectToken()) + .set("requested_token_type", stsTokenExchangeRequest.getRequestedTokenType()) + .set("actor_token", stsTokenExchangeRequest.getActingParty().getActorToken()) + .set("actor_token_type", stsTokenExchangeRequest.getActingParty().getActorTokenType()) + .set("resource", stsTokenExchangeRequest.getResource()) + .set("audience", stsTokenExchangeRequest.getAudience()); + + Map actualRequestContent = TestUtils.parseQuery(request.getContentAsString()); + assertEquals(expectedRequestContent.getUnknownKeys(), actualRequestContent); + } + + @Test + public void exchangeToken_throwsException() throws IOException { + StsTokenExchangeRequest stsTokenExchangeRequest = + StsTokenExchangeRequest.newBuilder("credential", "subjectTokenType").build(); + + final StsRequestHandler requestHandler = + StsRequestHandler.newBuilder( + TOKEN_URL, stsTokenExchangeRequest, transport.createRequestFactory()) + .build(); + + transport.addResponseErrorSequence( + TestUtils.buildHttpResponseException( + "invalidRequest", /* errorDescription= */ null, /* errorUri= */ null)); + + OAuthException e = + assertThrows( + OAuthException.class, + new ThrowingRunnable() { + @Override + public void run() throws Throwable { + requestHandler.exchangeToken(); + } + }); + + assertEquals("invalidRequest", e.getErrorCode()); + assertNull(e.getErrorDescription()); + assertNull(e.getErrorUri()); + } + + @Test + public void exchangeToken_withOptionalParams_throwsException() throws IOException { + StsTokenExchangeRequest stsTokenExchangeRequest = + StsTokenExchangeRequest.newBuilder("credential", "subjectTokenType").build(); + + final StsRequestHandler requestHandler = + StsRequestHandler.newBuilder( + TOKEN_URL, stsTokenExchangeRequest, transport.createRequestFactory()) + .build(); + + transport.addResponseErrorSequence( + TestUtils.buildHttpResponseException("invalidRequest", "errorDescription", "errorUri")); + + OAuthException e = + assertThrows( + OAuthException.class, + new ThrowingRunnable() { + @Override + public void run() throws Throwable { + requestHandler.exchangeToken(); + } + }); + + assertEquals("invalidRequest", e.getErrorCode()); + assertEquals("errorDescription", e.getErrorDescription()); + assertEquals("errorUri", e.getErrorUri()); + } + + @Test + public void exchangeToken_ioException() { + StsTokenExchangeRequest stsTokenExchangeRequest = + StsTokenExchangeRequest.newBuilder("credential", "subjectTokenType").build(); + + final StsRequestHandler requestHandler = + StsRequestHandler.newBuilder( + TOKEN_URL, stsTokenExchangeRequest, transport.createRequestFactory()) + .build(); + + IOException e = new IOException(); + transport.addResponseErrorSequence(e); + + IOException thrownException = + assertThrows( + IOException.class, + new ThrowingRunnable() { + @Override + public void run() throws Throwable { + requestHandler.exchangeToken(); + } + }); + assertEquals(e, thrownException); + } +} diff --git a/oauth2_http/testresources/aws_security_credentials.json b/oauth2_http/testresources/aws_security_credentials.json new file mode 100644 index 000000000..76e7688a3 --- /dev/null +++ b/oauth2_http/testresources/aws_security_credentials.json @@ -0,0 +1,9 @@ +{ + "Code" : "Success", + "LastUpdated" : "2020-08-11T19:33:07Z", + "Type" : "AWS-HMAC", + "AccessKeyId" : "ASIARD4OQDT6A77FR3CL", + "SecretAccessKey" : "Y8AfSaucF37G4PpvfguKZ3/l7Id4uocLXxX0+VTx", + "Token" : "IQoJb3JpZ2luX2VjEIz//////////wEaCXVzLWVhc3QtMiJGMEQCIH7MHX/Oy/OB8OlLQa9GrqU1B914+iMikqWQW7vPCKlgAiA/Lsv8Jcafn14owfxXn95FURZNKaaphj0ykpmS+Ki+CSq0AwhlEAAaDDA3NzA3MTM5MTk5NiIMx9sAeP1ovlMTMKLjKpEDwuJQg41/QUKx0laTZYjPlQvjwSqS3OB9P1KAXPWSLkliVMMqaHqelvMF/WO/glv3KwuTfQsavRNs3v5pcSEm4SPO3l7mCs7KrQUHwGP0neZhIKxEXy+Ls//1C/Bqt53NL+LSbaGv6RPHaX82laz2qElphg95aVLdYgIFY6JWV5fzyjgnhz0DQmy62/Vi8pNcM2/VnxeCQ8CC8dRDSt52ry2v+nc77vstuI9xV5k8mPtnaPoJDRANh0bjwY5Sdwkbp+mGRUJBAQRlNgHUJusefXQgVKBCiyJY4w3Csd8Bgj9IyDV+Azuy1jQqfFZWgP68LSz5bURyIjlWDQunO82stZ0BgplKKAa/KJHBPCp8Qi6i99uy7qh76FQAqgVTsnDuU6fGpHDcsDSGoCls2HgZjZFPeOj8mmRhFk1Xqvkbjuz8V1cJk54d3gIJvQt8gD2D6yJQZecnuGWd5K2e2HohvCc8Fc9kBl1300nUJPV+k4tr/A5R/0QfEKOZL1/k5lf1g9CREnrM8LVkGxCgdYMxLQow1uTL+QU67AHRRSp5PhhGX4Rek+01vdYSnJCMaPhSEgcLqDlQkhk6MPsyT91QMXcWmyO+cAZwUPwnRamFepuP4K8k2KVXs/LIJHLELwAZ0ekyaS7CptgOqS7uaSTFG3U+vzFZLEnGvWQ7y9IPNQZ+Dffgh4p3vF4J68y9049sI6Sr5d5wbKkcbm8hdCDHZcv4lnqohquPirLiFQ3q7B17V9krMPu3mz1cg4Ekgcrn/E09NTsxAqD8NcZ7C7ECom9r+X3zkDOxaajW6hu3Az8hGlyylDaMiFfRbBJpTIlxp7jfa7CxikNgNtEKLH9iCzvuSg2vhA==", + "Expiration" : "2020-08-11T07:35:49Z" +} \ No newline at end of file From 61810306dc0e18500a4a6b2704e00842fbecd879 Mon Sep 17 00:00:00 2001 From: Yoshi Automation Bot Date: Thu, 18 Feb 2021 15:06:53 -0800 Subject: [PATCH 08/10] fix: don't log downloads (#576) @chingor13 This change keeps Maven 3.6.1 and later from spamming our CI logs with page after page of lists of artifacts it's downloading that makes it much harder to find the actual test output. Source-Author: Elliotte Rusty Harold Source-Date: Thu Feb 18 19:58:59 2021 +0000 Source-Repo: googleapis/synthtool Source-Sha: 1aeca92e4a38f47134cb955f52ea76f84f09ff88 Source-Link: https://github.com/googleapis/synthtool/commit/1aeca92e4a38f47134cb955f52ea76f84f09ff88 --- .kokoro/build.sh | 2 ++ synth.metadata | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.kokoro/build.sh b/.kokoro/build.sh index ec1061bc1..c85b3b3dc 100755 --- a/.kokoro/build.sh +++ b/.kokoro/build.sh @@ -60,6 +60,7 @@ javadoc) ;; integration) mvn -B ${INTEGRATION_TEST_ARGS} \ + -ntp \ -Penable-integration-tests \ -DtrimStackTrace=false \ -Dclirr.skip=true \ @@ -81,6 +82,7 @@ samples) pushd ${SAMPLES_DIR} mvn -B \ -Penable-samples \ + -ntp \ -DtrimStackTrace=false \ -Dclirr.skip=true \ -Denforcer.skip=true \ diff --git a/synth.metadata b/synth.metadata index eb23e2a58..48440320a 100644 --- a/synth.metadata +++ b/synth.metadata @@ -4,14 +4,14 @@ "git": { "name": ".", "remote": "https://github.com/googleapis/google-auth-library-java.git", - "sha": "91df992970a2146790637cc8f34dec7936d16617" + "sha": "b8dde1e43f86a0a00741790c12d73f6cbda6251d" } }, { "git": { "name": "synthtool", "remote": "https://github.com/googleapis/synthtool.git", - "sha": "b416a7befcdbc42de41cf387dcf428f894fb812b" + "sha": "1aeca92e4a38f47134cb955f52ea76f84f09ff88" } } ], From 50f02169996400c5f2a7f1c68f81cbe16574c425 Mon Sep 17 00:00:00 2001 From: Yoshi Automation Bot Date: Fri, 19 Feb 2021 05:24:26 -0800 Subject: [PATCH 09/10] build: reduce download junk in log files (#577) * fix: less download junk log files * Update build.sh Source-Author: Elliotte Rusty Harold Source-Date: Fri Feb 19 01:42:29 2021 +0000 Source-Repo: googleapis/synthtool Source-Sha: 6946fd71ae9215b0e7ae188f5057df765ee6d7d2 Source-Link: https://github.com/googleapis/synthtool/commit/6946fd71ae9215b0e7ae188f5057df765ee6d7d2 --- .kokoro/build.sh | 2 +- .kokoro/dependencies.sh | 4 ++-- synth.metadata | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.kokoro/build.sh b/.kokoro/build.sh index c85b3b3dc..8774a93e7 100755 --- a/.kokoro/build.sh +++ b/.kokoro/build.sh @@ -29,7 +29,7 @@ echo ${JOB_TYPE} # attempt to install 3 times with exponential backoff (starting with 10 seconds) retry_with_backoff 3 10 \ - mvn install -B -V \ + mvn install -B -V -ntp \ -DskipTests=true \ -Dclirr.skip=true \ -Denforcer.skip=true \ diff --git a/.kokoro/dependencies.sh b/.kokoro/dependencies.sh index c91e5a569..0fb8c8436 100755 --- a/.kokoro/dependencies.sh +++ b/.kokoro/dependencies.sh @@ -31,7 +31,7 @@ export MAVEN_OPTS="-Xmx1024m -XX:MaxPermSize=128m" # this should run maven enforcer retry_with_backoff 3 10 \ - mvn install -B -V \ + mvn install -B -V -ntp \ -DskipTests=true \ -Dclirr.skip=true @@ -86,4 +86,4 @@ then else msg "Errors found. See log statements above." exit 1 -fi \ No newline at end of file +fi diff --git a/synth.metadata b/synth.metadata index 48440320a..e39ce6cd6 100644 --- a/synth.metadata +++ b/synth.metadata @@ -4,14 +4,14 @@ "git": { "name": ".", "remote": "https://github.com/googleapis/google-auth-library-java.git", - "sha": "b8dde1e43f86a0a00741790c12d73f6cbda6251d" + "sha": "61810306dc0e18500a4a6b2704e00842fbecd879" } }, { "git": { "name": "synthtool", "remote": "https://github.com/googleapis/synthtool.git", - "sha": "1aeca92e4a38f47134cb955f52ea76f84f09ff88" + "sha": "6946fd71ae9215b0e7ae188f5057df765ee6d7d2" } } ], From 035a455bf3903e1d3e174a1fcb8442c91a687288 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Mon, 22 Feb 2021 22:26:05 +0000 Subject: [PATCH 10/10] chore(master): release 0.24.0 (#575) :robot: I have created a release \*beep\* \*boop\* --- ## [0.24.0](https://www.github.com/googleapis/google-auth-library-java/compare/v0.23.0...v0.24.0) (2021-02-19) ### Features * add workload identity federation support ([#547](https://www.github.com/googleapis/google-auth-library-java/issues/547)) ([b8dde1e](https://www.github.com/googleapis/google-auth-library-java/commit/b8dde1e43f86a0a00741790c12d73f6cbda6251d)) ### Bug Fixes * don't log downloads ([#576](https://www.github.com/googleapis/google-auth-library-java/issues/576)) ([6181030](https://www.github.com/googleapis/google-auth-library-java/commit/61810306dc0e18500a4a6b2704e00842fbecd879)) ### Documentation * add instructions for using workload identity federation ([#564](https://www.github.com/googleapis/google-auth-library-java/issues/564)) ([2142db3](https://www.github.com/googleapis/google-auth-library-java/commit/2142db314666f298071ae30a7419b00d48d87476)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). --- CHANGELOG.md | 17 +++++++++++++++++ README.md | 6 +++--- appengine/pom.xml | 2 +- bom/pom.xml | 2 +- credentials/pom.xml | 2 +- oauth2_http/pom.xml | 2 +- pom.xml | 2 +- versions.txt | 12 ++++++------ 8 files changed, 31 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d6af2769..a53096103 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [0.24.0](https://www.github.com/googleapis/google-auth-library-java/compare/v0.23.0...v0.24.0) (2021-02-19) + + +### Features + +* add workload identity federation support ([#547](https://www.github.com/googleapis/google-auth-library-java/issues/547)) ([b8dde1e](https://www.github.com/googleapis/google-auth-library-java/commit/b8dde1e43f86a0a00741790c12d73f6cbda6251d)) + + +### Bug Fixes + +* don't log downloads ([#576](https://www.github.com/googleapis/google-auth-library-java/issues/576)) ([6181030](https://www.github.com/googleapis/google-auth-library-java/commit/61810306dc0e18500a4a6b2704e00842fbecd879)) + + +### Documentation + +* add instructions for using workload identity federation ([#564](https://www.github.com/googleapis/google-auth-library-java/issues/564)) ([2142db3](https://www.github.com/googleapis/google-auth-library-java/commit/2142db314666f298071ae30a7419b00d48d87476)) + ## [0.23.0](https://www.github.com/googleapis/google-auth-library-java/compare/v0.22.2...v0.23.0) (2021-01-26) diff --git a/README.md b/README.md index 7ef7021a4..24aac46f6 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ If you are using Maven, add this to your pom.xml file (notice that you can repla com.google.auth google-auth-library-oauth2-http - 0.23.0 + 0.24.0 ``` [//]: # ({x-version-update-end}) @@ -42,7 +42,7 @@ If you are using Gradle, add this to your dependencies [//]: # ({x-version-update-start:google-auth-library-oauth2-http:released}) ```Groovy -compile 'com.google.auth:google-auth-library-oauth2-http:0.23.0' +compile 'com.google.auth:google-auth-library-oauth2-http:0.24.0' ``` [//]: # ({x-version-update-end}) @@ -50,7 +50,7 @@ If you are using SBT, add this to your dependencies [//]: # ({x-version-update-start:google-auth-library-oauth2-http:released}) ```Scala -libraryDependencies += "com.google.auth" % "google-auth-library-oauth2-http" % "0.23.0" +libraryDependencies += "com.google.auth" % "google-auth-library-oauth2-http" % "0.24.0" ``` [//]: # ({x-version-update-end}) diff --git a/appengine/pom.xml b/appengine/pom.xml index d5cc23d4d..f8e91fea4 100644 --- a/appengine/pom.xml +++ b/appengine/pom.xml @@ -5,7 +5,7 @@ com.google.auth google-auth-library-parent - 0.23.1-SNAPSHOT + 0.24.0 ../pom.xml diff --git a/bom/pom.xml b/bom/pom.xml index eeb502dec..f916a613f 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -3,7 +3,7 @@ 4.0.0 com.google.auth google-auth-library-bom - 0.23.1-SNAPSHOT + 0.24.0 pom Google Auth Library for Java BOM diff --git a/credentials/pom.xml b/credentials/pom.xml index f8469d3bc..1bea3054c 100644 --- a/credentials/pom.xml +++ b/credentials/pom.xml @@ -4,7 +4,7 @@ com.google.auth google-auth-library-parent - 0.23.1-SNAPSHOT + 0.24.0 ../pom.xml diff --git a/oauth2_http/pom.xml b/oauth2_http/pom.xml index 7774aad37..ee9f8dacb 100644 --- a/oauth2_http/pom.xml +++ b/oauth2_http/pom.xml @@ -5,7 +5,7 @@ com.google.auth google-auth-library-parent - 0.23.1-SNAPSHOT + 0.24.0 ../pom.xml diff --git a/pom.xml b/pom.xml index 318881f05..c2edc3124 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 com.google.auth google-auth-library-parent - 0.23.1-SNAPSHOT + 0.24.0 pom Google Auth Library for Java Client libraries providing authentication and diff --git a/versions.txt b/versions.txt index b26eded59..d9ba06eaf 100644 --- a/versions.txt +++ b/versions.txt @@ -1,9 +1,9 @@ # Format: # module:released-version:current-version -google-auth-library:0.23.0:0.23.1-SNAPSHOT -google-auth-library-bom:0.23.0:0.23.1-SNAPSHOT -google-auth-library-parent:0.23.0:0.23.1-SNAPSHOT -google-auth-library-appengine:0.23.0:0.23.1-SNAPSHOT -google-auth-library-credentials:0.23.0:0.23.1-SNAPSHOT -google-auth-library-oauth2-http:0.23.0:0.23.1-SNAPSHOT +google-auth-library:0.24.0:0.24.0 +google-auth-library-bom:0.24.0:0.24.0 +google-auth-library-parent:0.24.0:0.24.0 +google-auth-library-appengine:0.24.0:0.24.0 +google-auth-library-credentials:0.24.0:0.24.0 +google-auth-library-oauth2-http:0.24.0:0.24.0