├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ ├── build.yml │ ├── fosstars.yml │ ├── lint.yml │ └── reuse.yml ├── .gitignore ├── .golangci.yml ├── .reuse └── dep5 ├── CONTRIBUTING.md ├── LICENSE ├── LICENSES └── Apache-2.0.txt ├── Makefile ├── README.md ├── auth ├── certificate.go ├── certificate_test.go ├── extractor.go ├── middleware.go ├── middleware_test.go ├── proofOfPossession.go ├── proofOfPossession_test.go ├── testdata │ └── x-forwarded-client-cert.txt ├── token.go ├── token_test.go ├── validator.go └── validator_test.go ├── env ├── environment.go ├── iasConfig.go ├── iasConfig_test.go └── testdata │ └── k8s │ ├── multi-instances │ ├── service-instance-1 │ │ └── clientid │ └── service-instance-2 │ │ └── clientid │ ├── single-instance-onecredentialsfile │ └── service-instance │ │ ├── credentials │ │ └── ignore │ └── single-instance │ └── service-instance │ ├── app_tid │ ├── authorization_bundle_url │ ├── authorization_instance_id │ ├── clientid │ ├── clientsecret │ ├── credentials │ ├── domains │ ├── ignore │ └── .gitkeep │ └── url ├── go.mod ├── go.sum ├── httpclient ├── httpclient.go ├── httpclient_test.go └── testdata │ ├── certificate.pem │ ├── otherTestingKey.pem │ └── privateTestingKey.pem ├── mocks ├── mockServer.go ├── mockServer_test.go ├── oidcClaims.go ├── oidcTokenBuilder.go └── testdata │ └── privateTestingKey.pem ├── oidcclient ├── jwk.go ├── jwk_test.go └── oidcClient.md ├── sample ├── manifest.yaml └── middleware.go ├── testutil ├── testdata │ └── privateTestingKey.pem ├── token.go └── token_test.go └── tokenclient ├── README.md ├── tokenFlows.go └── tokenFlows_test.go /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @SAP/cloud-security-ams-team 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | time: "10:00" 8 | timezone: Etc/UCT 9 | reviewers: 10 | - "cloud-security-ams-team" 11 | open-pull-requests-limit: 10 12 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build and test 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: { } 7 | jobs: 8 | build: 9 | runs-on: ${{ matrix.os }} 10 | strategy: 11 | matrix: 12 | os: [ ubuntu-latest, windows-latest, macos-latest ] 13 | go: [ '1.23', 'stable' ] # minimum version should be kept in sync with go version in go.mod 14 | fail-fast: false 15 | 16 | name: Go ${{ matrix.go }} ${{ matrix.os }} build 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Setup go 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version: ${{ matrix.go }} 24 | - name: Get dependencies 25 | run: | 26 | make get-deps 27 | - name: Build 28 | run: | 29 | make build 30 | - name: Test 31 | run: | 32 | make test 33 | -------------------------------------------------------------------------------- /.github/workflows/fosstars.yml: -------------------------------------------------------------------------------- 1 | name: "Fosstars (Security)" 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: "0 0 * * *" 6 | 7 | jobs: 8 | create_fosstars_report: 9 | runs-on: ubuntu-latest 10 | name: "Security rating" 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: SAP/fosstars-rating-core-action@v1.14.0 14 | with: 15 | report-branch: fosstars-report 16 | token: "${{ secrets.GITHUB_TOKEN }}" 17 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - master 7 | pull_request: 8 | 9 | permissions: 10 | contents: read 11 | # Optional: allow read access to pull request. Use with `only-new-issues` option. 12 | pull-requests: read 13 | 14 | jobs: 15 | golangci: 16 | name: lint 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: actions/setup-go@v5 21 | with: 22 | go-version: stable 23 | - name: golangci-lint 24 | uses: golangci/golangci-lint-action@v8 25 | with: 26 | version: v2.1.6 -------------------------------------------------------------------------------- /.github/workflows/reuse.yml: -------------------------------------------------------------------------------- 1 | name: REUSE 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: { } 8 | 9 | jobs: 10 | check: 11 | runs-on: ubuntu-latest 12 | name: "Compliance Check" 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: REUSE Compliance Check 16 | uses: fsfe/reuse-action@v5 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/go,macos,intellij+all 3 | # Edit at https://www.gitignore.io/?templates=go,macos,intellij+all 4 | 5 | ### Go ### 6 | # Binaries for programs and plugins 7 | *.exe 8 | *.exe~ 9 | *.dll 10 | *.so 11 | *.dylib 12 | 13 | # Test binary, built with `go test -c` 14 | *.test 15 | 16 | # Output of the go coverage tool, specifically when used with LiteIDE 17 | *.out 18 | 19 | # Dependency directories (remove the comment below to include it) 20 | # vendor/ 21 | 22 | ### Go Patch ### 23 | /vendor/ 24 | /Godeps/ 25 | 26 | ### Intellij+all ### 27 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 28 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 29 | 30 | # User-specific stuff 31 | .idea/**/workspace.xml 32 | .idea/**/tasks.xml 33 | .idea/**/usage.statistics.xml 34 | .idea/**/dictionaries 35 | .idea/**/shelf 36 | 37 | # Generated files 38 | .idea/**/contentModel.xml 39 | 40 | # Sensitive or high-churn files 41 | .idea/**/dataSources/ 42 | .idea/**/dataSources.ids 43 | .idea/**/dataSources.local.xml 44 | .idea/**/sqlDataSources.xml 45 | .idea/**/dynamic.xml 46 | .idea/**/uiDesigner.xml 47 | .idea/**/dbnavigator.xml 48 | 49 | # Gradle 50 | .idea/**/gradle.xml 51 | .idea/**/libraries 52 | 53 | # Gradle and Maven with auto-import 54 | # When using Gradle or Maven with auto-import, you should exclude module files, 55 | # since they will be recreated, and may cause churn. Uncomment if using 56 | # auto-import. 57 | # .idea/modules.xml 58 | # .idea/*.iml 59 | # .idea/modules 60 | # *.iml 61 | # *.ipr 62 | 63 | # CMake 64 | cmake-build-*/ 65 | 66 | # Mongo Explorer plugin 67 | .idea/**/mongoSettings.xml 68 | 69 | # File-based project format 70 | *.iws 71 | 72 | # IntelliJ 73 | out/ 74 | 75 | # mpeltonen/sbt-idea plugin 76 | .idea_modules/ 77 | 78 | # JIRA plugin 79 | atlassian-ide-plugin.xml 80 | 81 | # Cursive Clojure plugin 82 | .idea/replstate.xml 83 | 84 | # Crashlytics plugin (for Android Studio and IntelliJ) 85 | com_crashlytics_export_strings.xml 86 | crashlytics.properties 87 | crashlytics-build.properties 88 | fabric.properties 89 | 90 | # Editor-based Rest Client 91 | .idea/httpRequests 92 | 93 | # Android studio 3.1+ serialized cache file 94 | .idea/caches/build_file_checksums.ser 95 | 96 | ### Intellij+all Patch ### 97 | # Ignores the whole .idea folder and all .iml files 98 | # See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 99 | 100 | .idea/ 101 | 102 | # Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 103 | 104 | *.iml 105 | modules.xml 106 | .idea/misc.xml 107 | *.ipr 108 | 109 | # Sonarlint plugin 110 | .idea/sonarlint 111 | 112 | ### macOS ### 113 | # General 114 | .DS_Store 115 | .AppleDouble 116 | .LSOverride 117 | 118 | # Icon must end with two \r 119 | Icon 120 | 121 | # Thumbnails 122 | ._* 123 | 124 | # Files that might appear in the root of a volume 125 | .DocumentRevisions-V100 126 | .fseventsd 127 | .Spotlight-V100 128 | .TemporaryItems 129 | .Trashes 130 | .VolumeIcon.icns 131 | .com.apple.timemachine.donotpresent 132 | 133 | # Directories potentially created on remote AFP share 134 | .AppleDB 135 | .AppleDesktop 136 | Network Trash Folder 137 | Temporary Items 138 | .apdisk 139 | 140 | # End of https://www.gitignore.io/api/go,macos,intellij+all -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | default: none 4 | enable: 5 | - bodyclose 6 | - dogsled 7 | - dupl 8 | - errcheck 9 | - exhaustive 10 | - funlen 11 | - gochecknoinits 12 | - goconst 13 | - gocritic 14 | - gocyclo 15 | - goprintffuncname 16 | - gosec 17 | - govet 18 | - ineffassign 19 | - misspell 20 | - nakedret 21 | - noctx 22 | - nolintlint 23 | - revive 24 | - rowserrcheck 25 | - staticcheck 26 | - unconvert 27 | - unparam 28 | - unused 29 | - whitespace 30 | settings: 31 | dupl: 32 | threshold: 100 33 | funlen: 34 | lines: 100 35 | statements: 50 36 | goconst: 37 | min-len: 2 38 | min-occurrences: 2 39 | gocritic: 40 | disabled-checks: 41 | - dupImport 42 | - ifElseChain 43 | - octalLiteral 44 | - whyNoLint 45 | - wrapperFunc 46 | enabled-tags: 47 | - diagnostic 48 | - experimental 49 | - opinionated 50 | - performance 51 | - style 52 | gocyclo: 53 | min-complexity: 15 54 | lll: 55 | line-length: 140 56 | misspell: 57 | locale: US 58 | nolintlint: 59 | require-explanation: false 60 | require-specific: true 61 | allow-unused: false 62 | exclusions: 63 | generated: lax 64 | presets: 65 | - comments 66 | - common-false-positives 67 | - legacy 68 | - std-error-handling 69 | rules: 70 | - linters: 71 | - funlen 72 | - goconst 73 | - mnd 74 | - revive 75 | path: _test\.go 76 | - linters: 77 | - gocritic 78 | text: 'hugeParam:' 79 | - path: (.+)\.go$ 80 | text: Using the variable on range scope `tt` in function literal 81 | - path: (.+)\.go$ 82 | text: should have a package comment, unless it's in another file for this package 83 | paths: 84 | - third_party$ 85 | - builtin$ 86 | - examples$ 87 | formatters: 88 | enable: 89 | - gofmt 90 | exclusions: 91 | generated: lax 92 | paths: 93 | - third_party$ 94 | - builtin$ 95 | - examples$ 96 | -------------------------------------------------------------------------------- /.reuse/dep5: -------------------------------------------------------------------------------- 1 | Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | Upstream-Name: Cloud Security Client Go 3 | Upstream-Contact: SAP SE 4 | Source: github.com/sap/cloud-security-client-go 5 | Disclaimer: The code in this project may include calls to APIs (“API Calls”) of 6 | SAP or third-party products or services developed outside of this project 7 | (“External Products”). 8 | “APIs” means application programming interfaces, as well as their respective 9 | specifications and implementing code that allows software to communicate with 10 | other software. 11 | API Calls to External Products are not licensed under the open source license 12 | that governs this project. The use of such API Calls and related External 13 | Products are subject to applicable additional agreements with the relevant 14 | provider of the External Products. In no event shall the open source license 15 | that governs this project grant any rights in or to any External Products,or 16 | alter, expand or supersede any terms of the applicable additional agreements. 17 | If you have a valid license agreement with SAP for the use of a particular SAP 18 | External Product, then you may make use of any API Calls included in this 19 | project’s code for that SAP External Product, subject to the terms of such 20 | license agreement. If you do not have a valid license agreement for the use of 21 | a particular SAP External Product, then you may only make use of any API Calls 22 | in this project for that SAP External Product for your internal, non-productive 23 | and non-commercial test and evaluation of such API Calls. Nothing herein grants 24 | you any rights to use or access any SAP External Product, or provide any third 25 | parties the right to use of access any SAP External Product, through API Calls. 26 | 27 | Files: 28 | go.mod 29 | go.sum 30 | README.md 31 | CONTRIBUTING.md 32 | oidcclient/oidcClient.md 33 | .gitignore 34 | .github/** 35 | .golangci.yml 36 | env/testdata/** 37 | auth/testdata/** 38 | httpclient/testdata/** 39 | mocks/testdata/** 40 | testutil/testdata/** 41 | tokenclient/README.md 42 | sample/manifest.yaml 43 | Copyright: 2020-2021 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors 44 | License: Apache-2.0 -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution 2 | 3 | ## Description 4 | Contributions are welcome! Please open a pull request and we will provide feedback as soon as possible. 5 | 6 | Note that this project makes use of golangci-lint. 7 | To make use of our Makefile, please make sure you have installed [golangci-lint](https://golangci-lint.run/usage/install/#local-installation) on your local machine. 8 | 9 | All prerequisites for a pull request can then be checked with `make pull-request`. 10 | 11 | 12 | ## Contributing with AI-generated code 13 | 14 | As artificial intelligence evolves, AI-generated code is becoming valuable for many software projects, including open-source initiatives. While we recognize the potential benefits of incorporating AI-generated content into our open-source projects there a certain requirements that need to be reflected and adhered to when making contributions. 15 | 16 | Please see our [guideline for AI-generated code contributions to SAP Open Source Software Projects](https://github.com/SAP/.github/blob/main/CONTRIBUTING_USING_GENAI.md) for these requirements. 17 | 18 | 19 | ## Developer Certificate of Origin (DCO) 20 | 21 | Due to legal reasons, contributors will be asked to accept a DCO when they create the first pull request to this project. This happens in an automated fashion during the submission process. SAP uses [the standard DCO text of the Linux Foundation](https://developercertificate.org/). 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2020 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /LICENSES/Apache-2.0.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | 3 | Version 2.0, January 2004 4 | 5 | http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, 6 | AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | 11 | 12 | "License" shall mean the terms and conditions for use, reproduction, and distribution 13 | as defined by Sections 1 through 9 of this document. 14 | 15 | 16 | 17 | "Licensor" shall mean the copyright owner or entity authorized by the copyright 18 | owner that is granting the License. 19 | 20 | 21 | 22 | "Legal Entity" shall mean the union of the acting entity and all other entities 23 | that control, are controlled by, or are under common control with that entity. 24 | For the purposes of this definition, "control" means (i) the power, direct 25 | or indirect, to cause the direction or management of such entity, whether 26 | by contract or otherwise, or (ii) ownership of fifty percent (50%) or more 27 | of the outstanding shares, or (iii) beneficial ownership of such entity. 28 | 29 | 30 | 31 | "You" (or "Your") shall mean an individual or Legal Entity exercising permissions 32 | granted by this License. 33 | 34 | 35 | 36 | "Source" form shall mean the preferred form for making modifications, including 37 | but not limited to software source code, documentation source, and configuration 38 | files. 39 | 40 | 41 | 42 | "Object" form shall mean any form resulting from mechanical transformation 43 | or translation of a Source form, including but not limited to compiled object 44 | code, generated documentation, and conversions to other media types. 45 | 46 | 47 | 48 | "Work" shall mean the work of authorship, whether in Source or Object form, 49 | made available under the License, as indicated by a copyright notice that 50 | is included in or attached to the work (an example is provided in the Appendix 51 | below). 52 | 53 | 54 | 55 | "Derivative Works" shall mean any work, whether in Source or Object form, 56 | that is based on (or derived from) the Work and for which the editorial revisions, 57 | annotations, elaborations, or other modifications represent, as a whole, an 58 | original work of authorship. For the purposes of this License, Derivative 59 | Works shall not include works that remain separable from, or merely link (or 60 | bind by name) to the interfaces of, the Work and Derivative Works thereof. 61 | 62 | 63 | 64 | "Contribution" shall mean any work of authorship, including the original version 65 | of the Work and any modifications or additions to that Work or Derivative 66 | Works thereof, that is intentionally submitted to Licensor for inclusion in 67 | the Work by the copyright owner or by an individual or Legal Entity authorized 68 | to submit on behalf of the copyright owner. For the purposes of this definition, 69 | "submitted" means any form of electronic, verbal, or written communication 70 | sent to the Licensor or its representatives, including but not limited to 71 | communication on electronic mailing lists, source code control systems, and 72 | issue tracking systems that are managed by, or on behalf of, the Licensor 73 | for the purpose of discussing and improving the Work, but excluding communication 74 | that is conspicuously marked or otherwise designated in writing by the copyright 75 | owner as "Not a Contribution." 76 | 77 | 78 | 79 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf 80 | of whom a Contribution has been received by Licensor and subsequently incorporated 81 | within the Work. 82 | 83 | 2. Grant of Copyright License. Subject to the terms and conditions of this 84 | License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, 85 | no-charge, royalty-free, irrevocable copyright license to reproduce, prepare 86 | Derivative Works of, publicly display, publicly perform, sublicense, and distribute 87 | the Work and such Derivative Works in Source or Object form. 88 | 89 | 3. Grant of Patent License. Subject to the terms and conditions of this License, 90 | each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, 91 | no-charge, royalty-free, irrevocable (except as stated in this section) patent 92 | license to make, have made, use, offer to sell, sell, import, and otherwise 93 | transfer the Work, where such license applies only to those patent claims 94 | licensable by such Contributor that are necessarily infringed by their Contribution(s) 95 | alone or by combination of their Contribution(s) with the Work to which such 96 | Contribution(s) was submitted. If You institute patent litigation against 97 | any entity (including a cross-claim or counterclaim in a lawsuit) alleging 98 | that the Work or a Contribution incorporated within the Work constitutes direct 99 | or contributory patent infringement, then any patent licenses granted to You 100 | under this License for that Work shall terminate as of the date such litigation 101 | is filed. 102 | 103 | 4. Redistribution. You may reproduce and distribute copies of the Work or 104 | Derivative Works thereof in any medium, with or without modifications, and 105 | in Source or Object form, provided that You meet the following conditions: 106 | 107 | (a) You must give any other recipients of the Work or Derivative Works a copy 108 | of this License; and 109 | 110 | (b) You must cause any modified files to carry prominent notices stating that 111 | You changed the files; and 112 | 113 | (c) You must retain, in the Source form of any Derivative Works that You distribute, 114 | all copyright, patent, trademark, and attribution notices from the Source 115 | form of the Work, excluding those notices that do not pertain to any part 116 | of the Derivative Works; and 117 | 118 | (d) If the Work includes a "NOTICE" text file as part of its distribution, 119 | then any Derivative Works that You distribute must include a readable copy 120 | of the attribution notices contained within such NOTICE file, excluding those 121 | notices that do not pertain to any part of the Derivative Works, in at least 122 | one of the following places: within a NOTICE text file distributed as part 123 | of the Derivative Works; within the Source form or documentation, if provided 124 | along with the Derivative Works; or, within a display generated by the Derivative 125 | Works, if and wherever such third-party notices normally appear. The contents 126 | of the NOTICE file are for informational purposes only and do not modify the 127 | License. You may add Your own attribution notices within Derivative Works 128 | that You distribute, alongside or as an addendum to the NOTICE text from the 129 | Work, provided that such additional attribution notices cannot be construed 130 | as modifying the License. 131 | 132 | You may add Your own copyright statement to Your modifications and may provide 133 | additional or different license terms and conditions for use, reproduction, 134 | or distribution of Your modifications, or for any such Derivative Works as 135 | a whole, provided Your use, reproduction, and distribution of the Work otherwise 136 | complies with the conditions stated in this License. 137 | 138 | 5. Submission of Contributions. Unless You explicitly state otherwise, any 139 | Contribution intentionally submitted for inclusion in the Work by You to the 140 | Licensor shall be under the terms and conditions of this License, without 141 | any additional terms or conditions. Notwithstanding the above, nothing herein 142 | shall supersede or modify the terms of any separate license agreement you 143 | may have executed with Licensor regarding such Contributions. 144 | 145 | 6. Trademarks. This License does not grant permission to use the trade names, 146 | trademarks, service marks, or product names of the Licensor, except as required 147 | for reasonable and customary use in describing the origin of the Work and 148 | reproducing the content of the NOTICE file. 149 | 150 | 7. Disclaimer of Warranty. Unless required by applicable law or agreed to 151 | in writing, Licensor provides the Work (and each Contributor provides its 152 | Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 153 | KIND, either express or implied, including, without limitation, any warranties 154 | or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR 155 | A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness 156 | of using or redistributing the Work and assume any risks associated with Your 157 | exercise of permissions under this License. 158 | 159 | 8. Limitation of Liability. In no event and under no legal theory, whether 160 | in tort (including negligence), contract, or otherwise, unless required by 161 | applicable law (such as deliberate and grossly negligent acts) or agreed to 162 | in writing, shall any Contributor be liable to You for damages, including 163 | any direct, indirect, special, incidental, or consequential damages of any 164 | character arising as a result of this License or out of the use or inability 165 | to use the Work (including but not limited to damages for loss of goodwill, 166 | work stoppage, computer failure or malfunction, or any and all other commercial 167 | damages or losses), even if such Contributor has been advised of the possibility 168 | of such damages. 169 | 170 | 9. Accepting Warranty or Additional Liability. While redistributing the Work 171 | or Derivative Works thereof, You may choose to offer, and charge a fee for, 172 | acceptance of support, warranty, indemnity, or other liability obligations 173 | and/or rights consistent with this License. However, in accepting such obligations, 174 | You may act only on Your own behalf and on Your sole responsibility, not on 175 | behalf of any other Contributor, and only if You agree to indemnify, defend, 176 | and hold each Contributor harmless for any liability incurred by, or claims 177 | asserted against, such Contributor by reason of your accepting any such warranty 178 | or additional liability. END OF TERMS AND CONDITIONS 179 | 180 | APPENDIX: How to apply the Apache License to your work. 181 | 182 | To apply the Apache License to your work, attach the following boilerplate 183 | notice, with the fields enclosed by brackets "[]" replaced with your own identifying 184 | information. (Don't include the brackets!) The text should be enclosed in 185 | the appropriate comment syntax for the file format. We also recommend that 186 | a file or class name and description of purpose be included on the same "printed 187 | page" as the copyright notice for easier identification within third-party 188 | archives. 189 | 190 | Copyright 2020 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | 194 | you may not use this file except in compliance with the License. 195 | 196 | You may obtain a copy of the License at 197 | 198 | http://www.apache.org/licenses/LICENSE-2.0 199 | 200 | Unless required by applicable law or agreed to in writing, software 201 | 202 | distributed under the License is distributed on an "AS IS" BASIS, 203 | 204 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 205 | 206 | See the License for the specific language governing permissions and 207 | 208 | limitations under the License. 209 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | GO=go 6 | GOBUILD=$(GO) build 7 | GOCLEAN=$(GO) clean 8 | GOTEST=$(GO) test 9 | GOGET=$(GO) get 10 | GOVET=$(GO) vet 11 | GOLIST=$(GO) list 12 | 13 | GOBUILD_FLAGS=-v 14 | GOTEST_FLAGS=-v 15 | GOGET_FLAGS=-v 16 | 17 | .PHONY: help build get-deps test lint vet pull-request clean 18 | 19 | help: 20 | @echo "Makefile for SAP/cloud-security-client-go" 21 | @echo "" 22 | @echo "Usage:" 23 | @echo "" 24 | @echo " make " 25 | @echo "" 26 | @echo "The commands are:" 27 | @echo "" 28 | @echo " build Build the package" 29 | @echo " clean Run go clean" 30 | @echo " help Print this help text" 31 | @echo " get-deps Download the dependencies" 32 | @echo " lint Run golangci-lint" 33 | @echo " pull-request Run all tests required for a PR" 34 | @echo " test Run go test" 35 | @echo " vet Run go vet" 36 | 37 | build: get-deps 38 | $(GOBUILD) $(GOBUILD_FLAGS) ./... 39 | 40 | get-deps: 41 | $(GOGET) $(GOGET_FLAGS) -t -d ./... 42 | 43 | test: 44 | $(GOTEST) $(GOTEST_FLAGS) --tags unit ./... 45 | 46 | lint: 47 | golangci-lint run 48 | 49 | vet: 50 | $(GOVET) ./... 51 | 52 | pull-request: GOBUILD_FLAGS= 53 | pull-request: GOGET_FLAGS= 54 | pull-request: GOTEST_FLAGS= 55 | pull-request: build test vet lint 56 | @echo "" 57 | @echo "------------------------------------------------------" 58 | @echo "" 59 | @echo "You can submit your work to " 60 | @echo "github.com/SAP/cloud-security-client-go/pulls" 61 | @echo "" 62 | @echo "Thank you!" 63 | 64 | clean: 65 | @$(GOCLEAN) 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Go Cloud Security Integration 2 | 3 | [![PkgGoDev](https://pkg.go.dev/badge/github.com/sap/cloud-security-client-go/auth)](https://pkg.go.dev/github.com/sap/cloud-security-client-go/auth) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/SAP/cloud-security-client-go)](https://goreportcard.com/report/github.com/SAP/cloud-security-client-go) 5 | [![REUSE status](https://api.reuse.software/badge/github.com/SAP/cloud-security-client-go)](https://api.reuse.software/info/github.com/SAP/cloud-security-client-go) 6 | 7 | ## Description 8 | Client Library in GoLang for application developers requiring authentication with the SAP Identity Authentication Service (IAS). The library provides means for validating the Open ID Connect Token (OIDC) and accessing authentication information like user uuid, user attributes and audiences from the token. 9 | 10 | ## Supported Environments 11 | - Cloud Foundry 12 | - Kubernetes/Kyma as of 0.11 version 13 | 14 | ## Requirements 15 | In order to make use of this client library your application should be integrated with the [SAP Identity Authentication Service (IAS)](https://help.sap.com/viewer/6d6d63354d1242d185ab4830fc04feb1/LATEST/en-US/d17a116432d24470930ebea41977a888.html). 16 | 17 | ## Download and Installation 18 | This project is a library for applications or services and does not run standalone. 19 | When integrating, the most important package is `auth`. It contains means for parsing claims of the JWT and validation 20 | the token signature, audience, issuer and more. 21 | 22 | The client library works as a middleware and has to be instantiated with `NewMiddelware`. For authentication there are options: 23 | - Ready-to-use **Middleware Handler**: The `AuthenticationHandler` which implements the standard `http/Handler` interface. Thus, it can be used easily e.g. in an `gorilla/mux` router or a plain `http/Server` implementation. The claims can be retrieved with `auth.GetClaims(req)` in the HTTP handler. 24 | - **Authenticate func**: More flexible, can be wrapped with an own middleware func to propagate the users claims. 25 | 26 | ### Service configuration in Kubernetes environment 27 | To access service instance configurations from the application, Kubernetes secrets need to be provided as files in a volume mounted on application's container. Library will look up the configuration files on the `mountPath:"/etc/secrets/sapbtp/identity/"`. 28 | 29 | ### Usage Sample 30 | [samples/middleware.go](sample/middleware.go) 31 | 32 | ### Testing 33 | The client library offers an OIDC Mock Server with means to create arbitrary tokens for testing purposes. Examples for the usage of the Mock Server in combination with the OIDC Token Builder can be found in [auth/middleware_test.go](auth/middleware_test.go) 34 | 35 | ## Current limitations 36 | Not Known. 37 | 38 | ## How to obtain support 39 | In case of questions or bug or reports please open a GitHub Issue in this repository. 40 | 41 | ## Contribution 42 | Contributions are welcome! Please open a pull request and we will provide feedback as soon as possible. 43 | 44 | Note that this project makes use of golangci-lint. 45 | To make use of our Makefile, please make sure you have installed [golangci-lint](https://golangci-lint.run/usage/install/#local-installation) on your local machine. 46 | 47 | All prerequisites for a pull request can then be checked with `make pull-request`. 48 | 49 | More information can be found in [CONTRIBUTING.md](./CONTRIBUTING.md) 50 | 51 | ## Licensing 52 | Please see our [LICENSE](./LICENSE) for copyright and license information. Detailed information including third-party components and their licensing/copyright information is available via the [REUSE tool](https://api.reuse.software/info/github.com/SAP/cloud-security-client-go). 53 | -------------------------------------------------------------------------------- /auth/certificate.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2021 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | package auth 5 | 6 | import ( 7 | "bytes" 8 | "crypto/sha256" 9 | "crypto/x509" 10 | "encoding/base64" 11 | "encoding/pem" 12 | "fmt" 13 | ) 14 | 15 | // Certificate is the public API to access claims of the X509 client certificate. 16 | type Certificate struct { 17 | x509Cert *x509.Certificate 18 | } 19 | 20 | // newCertificate parses the X509 client certificate string. 21 | // It supports DER and PEM formatted certificates. 22 | // Returns nil, if certString is empty string. 23 | // Returns error in case of parsing error. 24 | func newCertificate(certString string) (*Certificate, error) { 25 | x509Cert, err := parseCertificate(certString) 26 | if x509Cert != nil { 27 | return &Certificate{ 28 | x509Cert: x509Cert, 29 | }, nil 30 | } 31 | return nil, err 32 | } 33 | 34 | // GetThumbprint returns the thumbprint without padding. 35 | func (c *Certificate) GetThumbprint() string { 36 | thumbprintBytes := sha256.Sum256(c.x509Cert.Raw) 37 | thumbprint := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(thumbprintBytes[:]) 38 | 39 | return thumbprint 40 | } 41 | 42 | func parseCertificate(certString string) (*x509.Certificate, error) { 43 | if certString == "" { 44 | return nil, nil 45 | } 46 | const PEMIndicator string = "-----BEGIN" 47 | decoded, err := base64.StdEncoding.DecodeString(certString) 48 | if err != nil { 49 | return nil, fmt.Errorf("cannot base64 decode certificate header: %w", err) 50 | } 51 | if bytes.HasPrefix(decoded, []byte(PEMIndicator)) { // in case of apache proxy 52 | pemBlock, err := pem.Decode(decoded) 53 | if pemBlock == nil { 54 | return nil, fmt.Errorf("cannot decode PEM formatted certificate header: %v", err) 55 | } 56 | decoded = pemBlock.Bytes 57 | } 58 | cert, err := x509.ParseCertificate(decoded) 59 | if err != nil { 60 | return nil, err 61 | } 62 | return cert, nil 63 | } 64 | -------------------------------------------------------------------------------- /auth/certificate_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2021 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | package auth 5 | 6 | import ( 7 | "crypto/rand" 8 | "crypto/rsa" 9 | "crypto/x509" 10 | "crypto/x509/pkix" 11 | _ "embed" 12 | "encoding/base64" 13 | "encoding/pem" 14 | "math/big" 15 | "testing" 16 | 17 | "github.com/lestrrat-go/jwx/jwt" 18 | "github.com/stretchr/testify/assert" 19 | "github.com/stretchr/testify/require" 20 | ) 21 | 22 | //go:embed testdata/x-forwarded-client-cert.txt 23 | var derCertFromFile string 24 | 25 | func TestCertificate(t *testing.T) { 26 | t.Run("newCertificate() returns nil when no certificate is given", func(t *testing.T) { 27 | cert, err := newCertificate("") 28 | assert.Nil(t, cert) 29 | assert.Nil(t, err) 30 | }) 31 | 32 | t.Run("newCertificate() fails when DER certificate is corrupt", func(t *testing.T) { 33 | cert, err := newCertificate("abc123") 34 | assert.Nil(t, cert) 35 | assert.Contains(t, err.Error(), "cannot base64 decode certificate header:") 36 | }) 37 | 38 | t.Run("newCertificate() fails when PEM certificate is corrupt", func(t *testing.T) { 39 | cert, err := newCertificate("LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUQxVENDQXIyZ0F3SUJBZ0lNSUxvRXNuTFFCdQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0t") 40 | assert.Nil(t, cert) 41 | assert.Contains(t, err.Error(), "cannot decode PEM formatted certificate header:") 42 | }) 43 | 44 | t.Run("GetThumbprint() for PEM formatted cert", func(t *testing.T) { 45 | cert, _ := newCertificate(convertToPEM(t, derCertFromFile)) 46 | assert.Equal(t, "fU-XoQlhMTpQsz9ArXl6zHIpMGuRO4ExLKdLRTc5VjM", cert.GetThumbprint()) 47 | }) 48 | 49 | t.Run("GetThumbprint() for DER formatted cert", func(t *testing.T) { 50 | cert, _ := newCertificate(derCertFromFile) 51 | assert.Equal(t, "fU-XoQlhMTpQsz9ArXl6zHIpMGuRO4ExLKdLRTc5VjM", cert.GetThumbprint()) 52 | }) 53 | } 54 | 55 | func generateToken(t *testing.T, claimCnfMemberX5tValue string) Token { 56 | token := jwt.New() 57 | cnfClaim := map[string]interface{}{ 58 | claimCnfMemberX5t: claimCnfMemberX5tValue, 59 | } 60 | err := token.Set(claimCnf, cnfClaim) 61 | require.NoError(t, err, "Failed to create token: %v", err) 62 | 63 | return Token{jwtToken: token} 64 | } 65 | 66 | func convertToPEM(t *testing.T, derCert string) string { 67 | x509Cert, err := newCertificate(derCert) 68 | require.NoError(t, err, "failed to create certificate: %v", err) 69 | 70 | bytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: x509Cert.x509Cert.Raw}) 71 | return base64.StdEncoding.EncodeToString(bytes) 72 | } 73 | 74 | func generateDERCert() string { 75 | key, _ := rsa.GenerateKey(rand.Reader, 512) //nolint:gosec 76 | 77 | issuerName := pkix.Name{ 78 | Organization: []string{"my-issuer-org"}, 79 | } 80 | template := x509.Certificate{ 81 | SerialNumber: big.NewInt(125), 82 | Subject: pkix.Name{ 83 | Organization: []string{"my-subject-org"}, 84 | }, 85 | Issuer: issuerName, 86 | } 87 | issTemplate := x509.Certificate{ 88 | SerialNumber: big.NewInt(12345), 89 | Subject: issuerName, 90 | Issuer: issuerName, 91 | } 92 | derBytes, _ := x509.CreateCertificate(rand.Reader, &template, &issTemplate, &key.PublicKey, key) 93 | 94 | return base64.StdEncoding.EncodeToString(derBytes) 95 | } 96 | -------------------------------------------------------------------------------- /auth/extractor.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package auth 6 | 7 | import ( 8 | "errors" 9 | "net/http" 10 | "strings" 11 | ) 12 | 13 | const authorization string = "Authorization" 14 | 15 | func extractRawToken(r *http.Request) (string, error) { 16 | authHeader := r.Header.Get(authorization) 17 | 18 | if authHeader != "" { 19 | splitAuthHeader := strings.Fields(strings.TrimSpace(authHeader)) 20 | if strings.EqualFold(splitAuthHeader[0], "bearer") && len(splitAuthHeader) == 2 { 21 | return splitAuthHeader[1], nil 22 | } 23 | } 24 | 25 | return "", errors.New("extracting token from request header failed") 26 | } 27 | -------------------------------------------------------------------------------- /auth/middleware.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package auth 6 | 7 | import ( 8 | "context" 9 | "log" 10 | "net/http" 11 | "time" 12 | 13 | "github.com/patrickmn/go-cache" 14 | "golang.org/x/sync/singleflight" 15 | 16 | "github.com/sap/cloud-security-client-go/env" 17 | "github.com/sap/cloud-security-client-go/httpclient" 18 | "github.com/sap/cloud-security-client-go/tokenclient" 19 | ) 20 | 21 | // The ContextKey type is used as a key for library related values in the go context. See also TokenCtxKey 22 | type ContextKey int 23 | 24 | // TokenCtxKey is the key that holds the authorization value (*OIDCClaims) in the request context 25 | // ClientCertificateCtxKey is the key that holds the x509 client certificate in the request context 26 | const ( 27 | TokenCtxKey ContextKey = 0 28 | ClientCertificateCtxKey ContextKey = 1 29 | cacheExpiration = 12 * time.Hour 30 | cacheCleanupInterval = 24 * time.Hour 31 | ) 32 | 33 | // ErrorHandler is the type for the Error Handler which is called on unsuccessful token validation and if the AuthenticationHandler middleware func is used 34 | type ErrorHandler func(w http.ResponseWriter, r *http.Request, err error) 35 | 36 | // Options can be used as a argument to instantiate a AuthMiddle with NewMiddleware. 37 | type Options struct { 38 | ErrorHandler ErrorHandler // ErrorHandler called if the jwt verification fails and the AuthenticationHandler middleware func is used. Default: DefaultErrorHandler 39 | HTTPClient *http.Client // HTTPClient which is used for OIDC discovery and to retrieve JWKs (JSON Web Keys). Default: basic http.Client with a timeout of 15 seconds 40 | } 41 | 42 | // TokenFromCtx retrieves the claims of a request which 43 | // have been injected before via the auth middleware 44 | func TokenFromCtx(r *http.Request) (Token, bool) { 45 | token, ok := r.Context().Value(TokenCtxKey).(Token) 46 | return token, ok 47 | } 48 | 49 | // ClientCertificateFromCtx retrieves the X.509 client certificate of a request which 50 | // have been injected before via the auth middleware 51 | func ClientCertificateFromCtx(r *http.Request) (*Certificate, bool) { 52 | cert, ok := r.Context().Value(ClientCertificateCtxKey).(*Certificate) 53 | return cert, ok 54 | } 55 | 56 | // Middleware is the main entrypoint to the authn client library, instantiate with NewMiddleware. It holds information about the oAuth config and configured options. 57 | // Use either the ready to use AuthenticationHandler as a middleware or implement your own middleware with the help of Authenticate. 58 | type Middleware struct { 59 | identity env.Identity 60 | options Options 61 | oidcTenants *cache.Cache // contains *oidcclient.OIDCTenant 62 | sf singleflight.Group 63 | tokenFlows *tokenclient.TokenFlows 64 | } 65 | 66 | // NewMiddleware instantiates a new Middleware with defaults for not provided Options. 67 | func NewMiddleware(identity env.Identity, options Options) *Middleware { 68 | m := new(Middleware) 69 | 70 | if identity != nil { 71 | m.identity = identity 72 | } else { 73 | log.Fatal("identity must not be nil, please refer to package env for default implementations") 74 | } 75 | if options.ErrorHandler == nil { 76 | options.ErrorHandler = DefaultErrorHandler 77 | } 78 | if options.HTTPClient == nil { 79 | tlsConfig, err := httpclient.DefaultTLSConfig(identity) 80 | if err != nil { 81 | log.Fatal("identity config provides invalid certificate/key: %w", err) 82 | } 83 | options.HTTPClient = httpclient.DefaultHTTPClient(tlsConfig) 84 | } 85 | m.options = options 86 | 87 | m.oidcTenants = cache.New(cacheExpiration, cacheCleanupInterval) 88 | 89 | return m 90 | } 91 | 92 | // GetTokenFlows creates or returns TokenFlows, otherwise error is returned 93 | func (m *Middleware) GetTokenFlows() (*tokenclient.TokenFlows, error) { 94 | if m.tokenFlows == nil { 95 | tokenFlows, err := tokenclient.NewTokenFlows(m.identity, tokenclient.Options{HTTPClient: m.options.HTTPClient}) 96 | if err != nil { 97 | return nil, err 98 | } 99 | m.tokenFlows = tokenFlows 100 | } 101 | return m.tokenFlows, nil 102 | } 103 | 104 | // Authenticate authenticates a request and returns the Token if validation was successful, otherwise error is returned 105 | func (m *Middleware) Authenticate(r *http.Request) (Token, error) { 106 | token, _, err := m.AuthenticateWithProofOfPossession(r) 107 | 108 | return token, err 109 | } 110 | 111 | // AuthenticateWithProofOfPossession authenticates a request and returns the Token and the client certificate if validation was successful, 112 | // otherwise error is returned 113 | func (m *Middleware) AuthenticateWithProofOfPossession(r *http.Request) (Token, *Certificate, error) { 114 | // get Token from Header 115 | rawToken, err := extractRawToken(r) 116 | if err != nil { 117 | return Token{}, nil, err 118 | } 119 | 120 | token, err := m.ParseAndValidateJWT(rawToken) 121 | if err != nil { 122 | return Token{}, nil, err 123 | } 124 | 125 | const forwardedClientCertHeader = "x-forwarded-client-cert" 126 | var cert *Certificate 127 | cert, err = newCertificate(r.Header.Get(forwardedClientCertHeader)) 128 | if err != nil { 129 | return Token{}, nil, err 130 | } 131 | if "1" == "" && cert != nil { // TODO integrate proof of possession into middleware 132 | err = validateCertificate(cert, token) 133 | if err != nil { 134 | return Token{}, nil, err 135 | } 136 | } 137 | 138 | return token, cert, nil 139 | } 140 | 141 | // AuthenticationHandler authenticates a request and injects the claims into 142 | // the request context. If the authentication (see Authenticate) does not succeed, 143 | // the specified error handler (see Options.ErrorHandler) will be called and 144 | // the current request will stop. 145 | // In case of successful authentication the request context is enriched with the token, 146 | // as well as the client certificate (if given). 147 | func (m *Middleware) AuthenticationHandler(next http.Handler) http.Handler { 148 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 149 | token, cert, err := m.AuthenticateWithProofOfPossession(r) 150 | 151 | if err != nil { 152 | m.options.ErrorHandler(w, r, err) 153 | return 154 | } 155 | 156 | ctx := context.WithValue(context.WithValue(r.Context(), TokenCtxKey, token), ClientCertificateCtxKey, cert) 157 | *r = *r.WithContext(ctx) 158 | 159 | // Continue serving http if jwt was valid 160 | next.ServeHTTP(w, r) 161 | }) 162 | } 163 | 164 | // ClearCache clears the entire storage of cached oidc tenants including their JWKs 165 | func (m *Middleware) ClearCache() { 166 | m.oidcTenants.Flush() 167 | } 168 | 169 | // DefaultErrorHandler responds with the error and HTTP status 401 170 | func DefaultErrorHandler(w http.ResponseWriter, _ *http.Request, err error) { 171 | http.Error(w, err.Error(), http.StatusUnauthorized) 172 | } 173 | -------------------------------------------------------------------------------- /auth/middleware_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package auth 6 | 7 | import ( 8 | "context" 9 | "io" 10 | "net/http" 11 | "net/http/httptest" 12 | "strings" 13 | "testing" 14 | "time" 15 | 16 | "github.com/google/uuid" 17 | "github.com/lestrrat-go/jwx/jwa" 18 | "github.com/stretchr/testify/assert" 19 | 20 | "github.com/sap/cloud-security-client-go/env" 21 | "github.com/sap/cloud-security-client-go/mocks" 22 | ) 23 | 24 | func TestEnd2End(t *testing.T) { 25 | testServer, oidcMockServer := GetTestServer("") 26 | client := testServer.Client() 27 | defer testServer.Close() 28 | defer oidcMockServer.Server.Close() 29 | 30 | customDomainTestServer, customDomainOidcMockServer := GetTestServer("https://custom.oidc-server.com/") 31 | customDomainClient := customDomainTestServer.Client() 32 | defer customDomainTestServer.Close() 33 | defer customDomainOidcMockServer.Server.Close() 34 | 35 | tests := []struct { 36 | name string 37 | header map[string]interface{} 38 | claims mocks.OIDCClaims 39 | optCustomDomainTest bool 40 | wantErr bool 41 | }{ 42 | { 43 | name: "valid", 44 | header: oidcMockServer.DefaultHeaders(), 45 | claims: oidcMockServer.DefaultClaims(), 46 | wantErr: false, 47 | }, { 48 | name: "valid with aud array", 49 | header: oidcMockServer.DefaultHeaders(), 50 | claims: mocks.NewOIDCClaimsBuilder(oidcMockServer.DefaultClaims()). 51 | Audience("notMyClient", oidcMockServer.Config.ClientID). 52 | Build(), 53 | wantErr: false, 54 | }, 55 | { 56 | name: "valid with single aud", 57 | header: oidcMockServer.DefaultHeaders(), 58 | claims: mocks.NewOIDCClaimsBuilder(oidcMockServer.DefaultClaims()). 59 | Audience(oidcMockServer.Config.ClientID). 60 | Build(), 61 | wantErr: false, 62 | }, 63 | { 64 | name: "no key id in token", 65 | header: mocks.NewOIDCHeaderBuilder(oidcMockServer.DefaultHeaders()). 66 | KeyID(""). 67 | Build(), 68 | claims: oidcMockServer.DefaultClaims(), 69 | wantErr: false, 70 | }, { 71 | name: "expired", 72 | header: oidcMockServer.DefaultHeaders(), 73 | claims: mocks.NewOIDCClaimsBuilder(oidcMockServer.DefaultClaims()). 74 | ExpiresAt(time.Now().Add(-1 * time.Minute)). 75 | Build(), 76 | wantErr: true, 77 | }, { 78 | name: "no expiry provided", 79 | header: oidcMockServer.DefaultHeaders(), 80 | claims: mocks.NewOIDCClaimsBuilder(oidcMockServer.DefaultClaims()). 81 | WithoutExpiresAt(). 82 | Build(), 83 | wantErr: true, 84 | }, { 85 | name: "before validity", 86 | header: oidcMockServer.DefaultHeaders(), 87 | claims: mocks.NewOIDCClaimsBuilder(oidcMockServer.DefaultClaims()). 88 | NotBefore(time.Now().Add(2 * time.Minute)). 89 | Build(), 90 | wantErr: true, 91 | }, { 92 | name: "wrong audience", 93 | header: oidcMockServer.DefaultHeaders(), 94 | claims: mocks.NewOIDCClaimsBuilder(oidcMockServer.DefaultClaims()). 95 | Audience("notMyClient"). 96 | Build(), 97 | wantErr: true, 98 | }, { 99 | name: "wrong audience array", 100 | header: oidcMockServer.DefaultHeaders(), 101 | claims: mocks.NewOIDCClaimsBuilder(oidcMockServer.DefaultClaims()). 102 | Audience("notMyClient", "neitherThisOne"). 103 | Build(), 104 | wantErr: true, 105 | }, { 106 | name: "wrong issuer", 107 | header: oidcMockServer.DefaultHeaders(), 108 | claims: mocks.NewOIDCClaimsBuilder(oidcMockServer.DefaultClaims()). 109 | Issuer("https://another.oidc-server.com/"). 110 | Build(), 111 | wantErr: true, 112 | }, { 113 | name: "custom issuer", 114 | header: customDomainOidcMockServer.DefaultHeaders(), 115 | claims: mocks.NewOIDCClaimsBuilder(customDomainOidcMockServer.DefaultClaims()). 116 | Issuer("https://custom.oidc-server.com/"). 117 | IasIssuer(customDomainOidcMockServer.Server.URL). 118 | Build(), 119 | optCustomDomainTest: true, 120 | wantErr: false, 121 | }, { 122 | name: "no http/s prefix for issuer", 123 | header: oidcMockServer.DefaultHeaders(), 124 | claims: mocks.NewOIDCClaimsBuilder(oidcMockServer.DefaultClaims()). 125 | Issuer("127.0.0.1:64004"). 126 | Build(), 127 | wantErr: true, 128 | }, { 129 | name: "issuer malicious", 130 | header: oidcMockServer.DefaultHeaders(), 131 | claims: mocks.NewOIDCClaimsBuilder(oidcMockServer.DefaultClaims()). 132 | Issuer(oidcMockServer.Server.URL + "?redirect=https://malicious.ondemand.com/tokens%3Ftenant=9451dd2etrial"). 133 | Build(), 134 | wantErr: true, 135 | }, { 136 | name: "issuer malicious2", 137 | header: oidcMockServer.DefaultHeaders(), 138 | claims: mocks.NewOIDCClaimsBuilder(oidcMockServer.DefaultClaims()). 139 | Issuer(oidcMockServer.Server.URL + "\\\\@malicious.ondemand.com"). 140 | Build(), 141 | wantErr: true, 142 | }, { 143 | name: "issuer malicious3", 144 | header: oidcMockServer.DefaultHeaders(), 145 | claims: mocks.NewOIDCClaimsBuilder(oidcMockServer.DefaultClaims()). 146 | Issuer(oidcMockServer.Server.URL + "@malicious.ondemand.com"). 147 | Build(), 148 | wantErr: true, 149 | }, { 150 | name: "issuer malicious4", 151 | header: oidcMockServer.DefaultHeaders(), 152 | claims: mocks.NewOIDCClaimsBuilder(oidcMockServer.DefaultClaims()). 153 | Issuer("https://malicious.ondemand.com/token_keys///" + oidcMockServer.Server.URL). 154 | Build(), 155 | wantErr: true, 156 | }, { 157 | name: "issuer malicious5", 158 | header: oidcMockServer.DefaultHeaders(), 159 | claims: mocks.NewOIDCClaimsBuilder(oidcMockServer.DefaultClaims()). 160 | Issuer("https://malicious.ondemand.com/token_keys@" + strings.TrimPrefix(oidcMockServer.Server.URL, "https://")). 161 | Build(), 162 | wantErr: true, 163 | }, { 164 | name: "issuer malicious6", 165 | header: oidcMockServer.DefaultHeaders(), 166 | claims: mocks.NewOIDCClaimsBuilder(oidcMockServer.DefaultClaims()). 167 | Issuer(oidcMockServer.Server.URL + "///malicious.ondemand.com/token_keys"). 168 | Build(), 169 | wantErr: true, 170 | }, { 171 | name: "issuer malicious7", 172 | header: oidcMockServer.DefaultHeaders(), 173 | claims: mocks.NewOIDCClaimsBuilder(oidcMockServer.DefaultClaims()). 174 | Issuer(oidcMockServer.Server.URL + "\\\\@malicious.ondemand.com/token_keys"). 175 | Build(), 176 | wantErr: true, 177 | }, { 178 | name: "issuer empty", 179 | header: oidcMockServer.DefaultHeaders(), 180 | claims: mocks.NewOIDCClaimsBuilder(oidcMockServer.DefaultClaims()). 181 | Issuer(""). 182 | Build(), 183 | wantErr: true, 184 | }, { 185 | name: "wrong key id", 186 | header: mocks.NewOIDCHeaderBuilder(oidcMockServer.DefaultHeaders()). 187 | KeyID("wrongKey"). 188 | Build(), 189 | claims: oidcMockServer.DefaultClaims(), 190 | wantErr: true, 191 | }, { 192 | name: "none algorithm", 193 | header: mocks.NewOIDCHeaderBuilder(oidcMockServer.DefaultHeaders()). 194 | Alg(jwa.NoSignature). 195 | Build(), 196 | claims: oidcMockServer.DefaultClaims(), 197 | wantErr: true, 198 | }, { 199 | name: "empty algorithm", 200 | header: mocks.NewOIDCHeaderBuilder(oidcMockServer.DefaultHeaders()). 201 | Alg(""). 202 | Build(), 203 | claims: oidcMockServer.DefaultClaims(), 204 | wantErr: true, 205 | }, { 206 | name: "wrong algorithm", 207 | header: mocks.NewOIDCHeaderBuilder(oidcMockServer.DefaultHeaders()). 208 | Alg(jwa.HS256). 209 | Build(), 210 | claims: oidcMockServer.DefaultClaims(), 211 | wantErr: true, 212 | }, { 213 | name: "jwks prioritize app_tid", 214 | header: oidcMockServer.DefaultHeaders(), 215 | claims: mocks.NewOIDCClaimsBuilder(oidcMockServer.DefaultClaims()). 216 | ZoneID(mocks.InvalidAppTID). 217 | Build(), 218 | wantErr: false, 219 | }, { 220 | name: "lib accepts any app_tid", 221 | header: oidcMockServer.DefaultHeaders(), 222 | claims: mocks.NewOIDCClaimsBuilder(oidcMockServer.DefaultClaims()). 223 | AppTID(uuid.New().String()). 224 | Build(), 225 | wantErr: false, 226 | }, { 227 | name: "lib rejects unaccepted app_tid", 228 | header: oidcMockServer.DefaultHeaders(), 229 | claims: mocks.NewOIDCClaimsBuilder(oidcMockServer.DefaultClaims()). 230 | AppTID(mocks.InvalidAppTID). 231 | Build(), 232 | wantErr: true, 233 | }, 234 | } 235 | 236 | for _, tt := range tests { 237 | tt := tt 238 | t.Run(tt.name, func(t *testing.T) { 239 | s, ts, c := oidcMockServer, testServer, client 240 | if tt.optCustomDomainTest { 241 | s = customDomainOidcMockServer 242 | ts = customDomainTestServer 243 | c = customDomainClient 244 | } 245 | 246 | timeout, cancelFunc := context.WithTimeout(context.Background(), 5*time.Second) 247 | defer cancelFunc() 248 | req, _ := http.NewRequestWithContext(timeout, http.MethodGet, ts.URL+"/helloWorld", http.NoBody) 249 | authHeader, err := s.SignToken(tt.claims, tt.header) 250 | if err != nil { 251 | t.Errorf("unable to sign provided test token: %v", err) 252 | } 253 | req.Header.Add("Authorization", "Bearer "+authHeader) 254 | response, err := c.Do(req) 255 | if err != nil { 256 | t.Errorf("unexpected error during request: %v", err) 257 | } 258 | defer response.Body.Close() 259 | 260 | if tt.wantErr == false { 261 | if response.StatusCode != 200 { 262 | t.Errorf("req to test server failed: expected: 200, got: %d", response.StatusCode) 263 | } 264 | } else { 265 | if response.StatusCode != 401 { 266 | t.Errorf("req to test server succeeded unexpectedly: expected: 401, got: %d", response.StatusCode) 267 | } 268 | } 269 | body, _ := io.ReadAll(response.Body) 270 | t.Log(string(body)) 271 | }) 272 | } 273 | } 274 | 275 | func GetTestHandler() http.HandlerFunc { 276 | return func(rw http.ResponseWriter, req *http.Request) { 277 | cert, ok := ClientCertificateFromCtx(req) 278 | if !ok { 279 | _, _ = rw.Write([]byte("cert not found in context")) 280 | } 281 | if cert != nil { 282 | _, _ = rw.Write([]byte("entered test handler using cert: " + string(cert.x509Cert.Raw))) 283 | } 284 | _, _ = rw.Write([]byte("entered test handler")) 285 | } 286 | } 287 | 288 | func GetTestServer(customIssuer string) (clientServer *httptest.Server, oidcServer *mocks.MockServer) { 289 | mockServer, _ := mocks.NewOIDCMockServerWithCustomIssuer(customIssuer) 290 | options := Options{ 291 | ErrorHandler: nil, 292 | HTTPClient: mockServer.Server.Client(), 293 | } 294 | middleware := NewMiddleware(mockServer.Config, options) 295 | server := httptest.NewTLSServer(middleware.AuthenticationHandler(GetTestHandler())) 296 | 297 | return server, mockServer 298 | } 299 | 300 | func TestGetTokenFlows_sameInstance(t *testing.T) { 301 | middleware := NewMiddleware(&env.DefaultIdentity{ 302 | ClientID: "09932670-9440-445d-be3e-432a97d7e2ef", 303 | ClientSecret: "[the_CLIENT.secret:3[/abc", 304 | URL: "https://mySaaS.accounts400.ondemand.com", 305 | }, Options{}) 306 | tokenFlows, err := middleware.GetTokenFlows() 307 | assert.NoError(t, err) 308 | assert.NotNil(t, tokenFlows) 309 | 310 | sameTokenFlows, err := middleware.GetTokenFlows() 311 | assert.NoError(t, err) 312 | assert.Same(t, tokenFlows, sameTokenFlows) 313 | } 314 | -------------------------------------------------------------------------------- /auth/proofOfPossession.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2021 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | package auth 5 | 6 | // validateCertificate checks proof of possession in addition to audience validation 7 | // to make sure that it was called by a trust-worthy consumer. 8 | // Trust between application and applications/services is established with certificates in principle. 9 | // Proof of possession uses certificates as proof token and therefore, x.509 based mTLS communication is demanded. 10 | import ( 11 | "errors" 12 | "fmt" 13 | ) 14 | 15 | var ErrNoClientCert = errors.New("there is no x509 client certificate provided") 16 | 17 | // validateCertificate runs all proof of possession checks. 18 | // This ensures that the token was issued for the sender. 19 | func validateCertificate(clientCertificate *Certificate, token Token) error { 20 | if clientCertificate == nil { 21 | return ErrNoClientCert 22 | } 23 | return validateX5tThumbprint(clientCertificate, token) 24 | } 25 | 26 | // validateX5tThumbprint compares the thumbprint of the provided X509 client certificate against the cnf claim with the confirmation method "x5t#S256". 27 | // This ensures that the token was issued for the sender. 28 | func validateX5tThumbprint(clientCertificate *Certificate, token Token) error { 29 | if clientCertificate == nil { 30 | return ErrNoClientCert 31 | } 32 | 33 | cnfThumbprint := token.getCnfClaimMember(claimCnfMemberX5t) 34 | if cnfThumbprint == "" { 35 | return fmt.Errorf("token provides no cnf member for thumbprint confirmation") 36 | } 37 | 38 | if cnfThumbprint != clientCertificate.GetThumbprint() { 39 | return fmt.Errorf("token thumbprint confirmation failed") 40 | } 41 | return nil 42 | } 43 | -------------------------------------------------------------------------------- /auth/proofOfPossession_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2021 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | package auth 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | var derCertGenerated = generateDERCert() 14 | 15 | func TestProofOfPossession_parseAndValidateCertificate_edgeCases(t *testing.T) { 16 | t.Run("validateCertificate() fails when no cert is given", func(t *testing.T) { 17 | err := validateX5tThumbprint(nil, generateToken(t, "abc")) 18 | assert.Equal(t, "there is no x509 client certificate provided", err.Error()) 19 | }) 20 | 21 | t.Run("validateCertificate() fails when cert does not match x5t", func(t *testing.T) { 22 | x509Cert, err := newCertificate(derCertGenerated) 23 | require.NoError(t, err, "Failed to parse cert header: %v", err) 24 | err = validateX5tThumbprint(x509Cert, generateToken(t, "abc")) 25 | assert.Equal(t, "token thumbprint confirmation failed", err.Error()) 26 | }) 27 | } 28 | 29 | func TestProofOfPossession_validateX5tThumbprint_edgeCases(t *testing.T) { 30 | t.Run("validateX5tThumbprint() fails when no cert is given", func(t *testing.T) { 31 | err := validateX5tThumbprint(nil, generateToken(t, "abc")) 32 | assert.Equal(t, "there is no x509 client certificate provided", err.Error()) 33 | }) 34 | } 35 | 36 | func TestProofOfPossession_validateX5tThumbprint(t *testing.T) { 37 | tests := []struct { 38 | name string 39 | claimCnfMemberX5t string 40 | cert string 41 | pemEncoded bool 42 | expectedErrMsg string // in case of empty string no error is expected 43 | }{ 44 | { 45 | name: "x5t should match with DER certificate (HAProxy)", 46 | claimCnfMemberX5t: "fU-XoQlhMTpQsz9ArXl6zHIpMGuRO4ExLKdLRTc5VjM", 47 | cert: derCertFromFile, 48 | pemEncoded: false, 49 | expectedErrMsg: "", 50 | }, { 51 | name: "x5t should match with PEM certificate (apache proxy)", 52 | claimCnfMemberX5t: "fU-XoQlhMTpQsz9ArXl6zHIpMGuRO4ExLKdLRTc5VjM", 53 | cert: derCertFromFile, 54 | pemEncoded: true, 55 | expectedErrMsg: "", 56 | }, { 57 | name: "expect error when x5t does not match with generated DER certificate (HAProxy)", 58 | claimCnfMemberX5t: "fU-XoQlhMTpQsz9ArXl6zHIpMGuRO4ExLKdLRTc5VjM", 59 | cert: derCertGenerated, 60 | pemEncoded: false, 61 | expectedErrMsg: "token thumbprint confirmation failed", 62 | }, { 63 | name: "expect error when x5t does not match with generated PEM certificate (apache proxy)", 64 | claimCnfMemberX5t: "fU-XoQlhMTpQsz9ArXl6zHIpMGuRO4ExLKdLRTc5VjM", 65 | cert: derCertGenerated, 66 | pemEncoded: true, 67 | expectedErrMsg: "token thumbprint confirmation failed", 68 | }, { 69 | name: "expect error when x5t is empty", 70 | claimCnfMemberX5t: "", 71 | cert: derCertGenerated, 72 | pemEncoded: false, 73 | expectedErrMsg: "token provides no cnf member for thumbprint confirmation", 74 | }, 75 | } 76 | for _, tt := range tests { 77 | tt := tt 78 | t.Run(tt.name, func(t *testing.T) { 79 | cert := tt.cert 80 | if tt.pemEncoded == true { 81 | cert = convertToPEM(t, tt.cert) 82 | } 83 | x509cert, err := newCertificate(cert) 84 | require.NoError(t, err, "Failed to validate client cert with token cnf thumbprint: %v", err) 85 | 86 | err = validateX5tThumbprint(x509cert, generateToken(t, tt.claimCnfMemberX5t)) 87 | if tt.expectedErrMsg != "" { 88 | assert.Equal(t, tt.expectedErrMsg, err.Error()) 89 | } else { 90 | require.NoError(t, err, "Failed to validate client cert with token cnf thumbprint: %v", err) 91 | } 92 | }) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /auth/testdata/x-forwarded-client-cert.txt: -------------------------------------------------------------------------------- 1 | MIIGHzCCBAegAwIBAgIQG9beRcjzTikmC1QhzBARdjANBgkqhkiG9w0BAQsFADCBgDELMAkGA1UEBhMCREUxFDASBgNVBAcMC0VVMTAtQ2FuYXJ5MQ8wDQYDVQQKDAZTQVAgU0UxIzAhBgNVBAsMGlNBUCBDbG91ZCBQbGF0Zm9ybSBDbGllbnRzMSUwIwYDVQQDDBxTQVAgQ2xvdWQgUGxhdGZvcm0gQ2xpZW50IENBMB4XDTIxMDMxMjA3MzMwOVoXDTIxMDQxMTA4MzMwOVowggEEMQswCQYDVQQGEwJERTEPMA0GA1UEChMGU0FQIFNFMSMwIQYDVQQLExpTQVAgQ2xvdWQgUGxhdGZvcm0gQ2xpZW50czEPMA0GA1UECxMGQ2FuYXJ5MS0wKwYDVQQLEyQ4ZTFhZmZiMi02MmExLTQzY2MtYTY4Ny0yYmE3NWU0YjNkODQxKzApBgNVBAcTImFveGsyYWRkaC5hY2NvdW50czQwMC5vbmRlbWFuZC5jb20xUjBQBgNVBAMTSWJkY2QzMDBjLWIyMDItNGE3YS1iYjk1LTJhN2U2ZDE1ZmU0Ny8yYjU4NTQwNS1kMzkxLTQ5ODYtYjc2ZC1iNGYyNDY4NWYzYzgwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDIHzklky3oEib0ovw27WIIyvjkAPQtCYmShS2uQzHsj7Nn9Zp9EPQL2mjYxS2xNj/HXAsbxr6jk+lLrzvdYUNtlQ1XnHIZSBhPyRtQDw1BGHFd38Be70D6rif5s+vDUApvDuOYpLgDBFVgzr25F+47t83lcyWQ1wrKj4wo+aJt5rZrFUAGQCjOqHvccLK8YLmE0p7F444I0CCGxxXg4yQshNFjb3V2Bg+G/gXSYC3gLHX3SPYBhEyM8mm9HoUZ67JEkfM+sPT7OhFL7sLQe2jQ3MK4Z3DgeWLKAxnxLRRdho8sm29fdnt4d8eWbw7N8A2dHkASYvRk2I/tVoVaoAfrAgMBAAGjggEMMIIBCDAJBgNVHRMEAjAAMB8GA1UdIwQYMBaAFMwghFuHFYs3+X6Hsck+w2+VBG6RMB0GA1UdDgQWBBRqE7s38FCAhklB5+Cskt/xL0JaxjAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwIwgZUGA1UdHwSBjTCBijCBh6CBhKCBgYZ/aHR0cDovL3NhcC1jbG91ZC1wbGF0Zm9ybS1jbGllbnQtY2EtZXUxMC1jYW5hcnktY3Jscy5zMy5ldS1jZW50cmFsLTEuYW1hem9uYXdzLmNvbS9jcmwvZjhhYmU1NGUtMTk1MS00NzBlLWFlMmQtZGU0MGMxNjMzNDFjLmNybDANBgkqhkiG9w0BAQsFAAOCAgEAMwpK54htes+cuSB/iXZRe9bcNpELl7c8eVozEEa5BN1AmFHdEwFNS9M55Y5fHobBGxhOGjAy6UlrbHqV1Wo2d5LxGIF1a1hs7xPbP1GU+moCVnMH60w8d7kPcL7kb5y/RITk7NDgxEhcUC6TrVEiQtfkbw4i3rRyP8Yf6x8QuNwr6c2Zf7yrmFWlFKBrjMMN4dJ+AVKIJyFqPlbdbbBsJegtgo2KqYV8cXCzXMLRhBDkvvHx7Hoz94or6PZ7SPw8bKwwKrAP6/+6Z81q/WKGZwrmYkGi+aji0ocm6n8RyVFQP+wnBJPzn0qD3RNj0afhzvMproRr9O6WJ+tYFhQ2Lnx27+jNP33KBDWLc1XcUanyVtt/pJmbDtzTGD9LDhARX2+a0MciVZcC5WLV4zr9EaGT+a0NrlA1VjxjVTDcXwB3T2KnlskM6aEGodoWGPgfvpaq5IX+pfb/aoeTR3EcXHl8E8WR0aPbpDi8GWGCkqovGDbisnCKJurHRVOx8YE+69tX2qJAuevUENQfUovEWUYtA8UufTpu8TizXxTlt1/X69AdUgnFqLmyNkdd4g7NJoc7A3zjJXin5jwM6a8yjwxvco0fTlqobzngeJhwckcSkR3QvYlVl61ErxTdEhBlH5q1fsQ88tTwZ+x/RKApN80koCrqkj8ubeXc/fDQhj0= -------------------------------------------------------------------------------- /auth/token.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package auth 6 | 7 | import ( 8 | "context" 9 | "errors" 10 | "fmt" 11 | "time" 12 | 13 | "github.com/lestrrat-go/jwx/jwt" 14 | "github.com/lestrrat-go/jwx/jwt/openid" 15 | ) 16 | 17 | const ( 18 | claimCnf = "cnf" 19 | claimCnfMemberX5t = "x5t#S256" 20 | claimGivenName = "given_name" 21 | claimFamilyName = "family_name" 22 | claimEmail = "email" 23 | claimSapGlobalUserID = "user_uuid" 24 | claimSapGlobalZoneID = "zone_uuid" // tenant GUID 25 | claimSapGlobalAppTID = "app_tid" 26 | claimIasIssuer = "ias_iss" 27 | claimAzp = "azp" 28 | claimScimID = "scim_id" 29 | claimGroups = "groups" 30 | claimIasAPIs = "ias_apis" 31 | ) 32 | 33 | type Token struct { 34 | encodedToken string 35 | jwtToken jwt.Token 36 | } 37 | 38 | // NewToken creates a Token from an encoded jwt. !!! WARNING !!! No validation done when creating a Token this way. Use only in tests! 39 | func NewToken(encodedToken string) (Token, error) { 40 | decodedToken, err := jwt.ParseString(encodedToken, jwt.WithToken(openid.New())) 41 | if err != nil { 42 | return Token{}, err 43 | } 44 | 45 | return Token{ 46 | encodedToken: encodedToken, 47 | jwtToken: decodedToken, // encapsulates jwt.token_gen from github.com/lestrrat-go/jwx/jwt 48 | }, nil 49 | } 50 | 51 | // TokenValue returns encoded token string 52 | func (t Token) TokenValue() string { 53 | return t.encodedToken 54 | } 55 | 56 | // Audience returns "aud" claim, if it doesn't exist empty string is returned 57 | func (t Token) Audience() []string { 58 | return t.jwtToken.Audience() 59 | } 60 | 61 | // Expiration returns "exp" claim, if it doesn't exist empty string is returned 62 | func (t Token) Expiration() time.Time { 63 | return t.jwtToken.Expiration() 64 | } 65 | 66 | // IsExpired returns true, if 'exp' claim + leeway time of 1 minute is before current time 67 | func (t Token) IsExpired() bool { 68 | return t.Expiration().Add(1 * time.Minute).Before(time.Now()) 69 | } 70 | 71 | // IssuedAt returns "iat" claim, if it doesn't exist empty string is returned 72 | func (t Token) IssuedAt() time.Time { 73 | return t.jwtToken.IssuedAt() 74 | } 75 | 76 | // CustomIssuer returns "iss" claim if it is a custom domain (i.e. "ias_iss" claim available), otherwise empty string is returned 77 | func (t Token) CustomIssuer() string { 78 | // only return iss if ias_iss does exist 79 | if !t.HasClaim(claimIasIssuer) { 80 | return "" 81 | } 82 | return t.jwtToken.Issuer() 83 | } 84 | 85 | // Issuer returns token issuer with SAP domain; by default "iss" claim is returned or in case it is a custom domain, "ias_iss" is returned 86 | func (t Token) Issuer() string { 87 | // return standard issuer if ias_iss is not set 88 | v, err := t.GetClaimAsString(claimIasIssuer) 89 | if errors.Is(err, ErrClaimNotExists) { 90 | return t.jwtToken.Issuer() 91 | } 92 | return v 93 | } 94 | 95 | // NotBefore returns "nbf" claim, if it doesn't exist empty string is returned 96 | func (t Token) NotBefore() time.Time { 97 | return t.jwtToken.NotBefore() 98 | } 99 | 100 | // Subject returns "sub" claim, if it doesn't exist empty string is returned 101 | func (t Token) Subject() string { 102 | return t.jwtToken.Subject() 103 | } 104 | 105 | // GivenName returns "given_name" claim, if it doesn't exist empty string is returned 106 | func (t Token) GivenName() string { 107 | v, _ := t.GetClaimAsString(claimGivenName) 108 | return v 109 | } 110 | 111 | // FamilyName returns "family_name" claim, if it doesn't exist empty string is returned 112 | func (t Token) FamilyName() string { 113 | v, _ := t.GetClaimAsString(claimFamilyName) 114 | return v 115 | } 116 | 117 | // Email returns "email" claim, if it doesn't exist empty string is returned 118 | func (t Token) Email() string { 119 | v, _ := t.GetClaimAsString(claimEmail) 120 | return v 121 | } 122 | 123 | // ZoneID returns "app_tid" claim, if it doesn't exist empty string is returned 124 | // Deprecated: is replaced by AppTID and will be removed with the next major release 125 | func (t Token) ZoneID() string { 126 | appTID := t.AppTID() 127 | if appTID == "" { 128 | zoneUUID, _ := t.GetClaimAsString(claimSapGlobalZoneID) 129 | return zoneUUID 130 | } 131 | return appTID 132 | } 133 | 134 | // AppTID returns "app_tid" claim, if it doesn't exist empty string is returned 135 | func (t Token) AppTID() string { 136 | appTID, _ := t.GetClaimAsString(claimSapGlobalAppTID) 137 | return appTID 138 | } 139 | 140 | // Azp returns "azp" claim, if it doesn't exist empty string is returned 141 | func (t Token) Azp() string { 142 | appTID, _ := t.GetClaimAsString(claimAzp) 143 | return appTID 144 | } 145 | 146 | // UserUUID returns "user_uuid" claim, if it doesn't exist empty string is returned 147 | func (t Token) UserUUID() string { 148 | v, _ := t.GetClaimAsString(claimSapGlobalUserID) 149 | return v 150 | } 151 | 152 | // ScimID returns "scim_id" claim, if it doesn't exist empty string is returned 153 | func (t Token) ScimID() string { 154 | v, _ := t.GetClaimAsString(claimScimID) 155 | return v 156 | } 157 | 158 | // Groups returns "groups" claim, if it doesn't exist empty string is returned 159 | func (t Token) Groups() []string { 160 | v, _ := t.GetClaimAsStringSlice(claimGroups) 161 | return v 162 | } 163 | 164 | // ErrClaimNotExists shows that the requested custom claim does not exist in the token 165 | var ErrClaimNotExists = errors.New("claim does not exist in the token") 166 | 167 | // HasClaim returns true if the provided claim exists in the token 168 | func (t Token) HasClaim(claim string) bool { 169 | _, exists := t.jwtToken.Get(claim) 170 | return exists 171 | } 172 | 173 | // GetClaimAsString returns a custom claim type asserted as string. Returns error if the claim is not available or not a string. 174 | func (t Token) GetClaimAsString(claim string) (string, error) { 175 | value, exists := t.jwtToken.Get(claim) 176 | if !exists { 177 | return "", ErrClaimNotExists 178 | } 179 | stringValue, ok := value.(string) 180 | if !ok { 181 | return "", fmt.Errorf("unable to assert claim %s type as string. Actual type: %T", claim, value) 182 | } 183 | return stringValue, nil 184 | } 185 | 186 | // GetClaimAsStringSlice returns a custom claim type asserted as string slice. The claim name is case-sensitive. Returns error if the claim is not available or not an array 187 | func (t Token) GetClaimAsStringSlice(claim string) ([]string, error) { 188 | value, exists := t.jwtToken.Get(claim) 189 | if !exists { 190 | return nil, ErrClaimNotExists 191 | } 192 | switch v := value.(type) { 193 | case string: 194 | return []string{v}, nil 195 | case []interface{}: 196 | strArr := make([]string, len(v)) 197 | for i, elem := range v { 198 | strVal, ok := elem.(string) 199 | if !ok { 200 | return nil, fmt.Errorf("unable to assert array element as string. Actual type: %T", elem) 201 | } 202 | strArr[i] = strVal 203 | } 204 | return strArr, nil 205 | case []string: 206 | return v, nil 207 | default: 208 | return nil, fmt.Errorf("unable to assert claim %s type as string or []string. Actual type: %T", claim, value) 209 | } 210 | } 211 | 212 | // GetAllClaimsAsMap returns a map of all claims contained in the token. The claim name is case sensitive. Includes also custom claims 213 | func (t Token) GetAllClaimsAsMap() map[string]interface{} { 214 | mapClaims, _ := t.jwtToken.AsMap(context.TODO()) // err can not really occur on jwt.Token 215 | return mapClaims 216 | } 217 | 218 | // GetClaimAsMap returns a map of all members and its values of a custom claim in the token. The member name is case sensitive. Returns error if the claim is not available or not a map 219 | func (t Token) GetClaimAsMap(claim string) (map[string]interface{}, error) { 220 | value, exists := t.jwtToken.Get(claim) 221 | if !exists { 222 | return nil, ErrClaimNotExists 223 | } 224 | res, ok := value.(map[string]interface{}) 225 | if !ok { 226 | return nil, fmt.Errorf("unable to assert type of claim %s to map[string]interface{}. Actual type: %T", claim, value) 227 | } 228 | return res, nil 229 | } 230 | 231 | func (t Token) getJwtToken() jwt.Token { 232 | return t.jwtToken 233 | } 234 | 235 | func (t Token) getCnfClaimMember(memberName string) string { 236 | cnfClaim, err := t.GetClaimAsMap(claimCnf) 237 | if errors.Is(err, ErrClaimNotExists) || cnfClaim == nil { 238 | return "" 239 | } 240 | res, ok := cnfClaim[memberName] 241 | if ok { 242 | return res.(string) 243 | } 244 | return "" 245 | } 246 | -------------------------------------------------------------------------------- /auth/token_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package auth 6 | 7 | import ( 8 | "reflect" 9 | "testing" 10 | 11 | "github.com/lestrrat-go/jwx/jwt" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestToken_getClaimAsString(t *testing.T) { 16 | t.Parallel() 17 | 18 | tests := []struct { 19 | name string 20 | claimValue interface{} 21 | claimArg string 22 | want string 23 | wantErr bool 24 | }{ 25 | { 26 | name: "single string", 27 | claimValue: "testValue", 28 | claimArg: "testClaim", 29 | want: "testValue", 30 | wantErr: false, 31 | }, { 32 | name: "single int", 33 | claimValue: 1, 34 | claimArg: "testClaim", 35 | want: "", 36 | wantErr: true, 37 | }, { 38 | name: "string slice", 39 | claimValue: []string{"oneString", "anotherOne"}, 40 | claimArg: "testClaim", 41 | want: "", 42 | wantErr: true, 43 | }, 44 | } 45 | for _, tt := range tests { 46 | tt := tt 47 | t.Run(tt.name, func(t *testing.T) { 48 | token := jwt.New() 49 | err := token.Set(tt.claimArg, tt.claimValue) 50 | if err != nil { 51 | t.Errorf("Error preparing test: %v", err) 52 | } 53 | stdToken := Token{ 54 | jwtToken: token, 55 | } 56 | got, err := stdToken.GetClaimAsString(tt.claimArg) 57 | if (err != nil) != tt.wantErr { 58 | t.Errorf("GetClaimAsString() error = %v, wantErr %v", err, tt.wantErr) 59 | return 60 | } 61 | if got != tt.want { 62 | t.Errorf("GetClaimAsString() got = %v, want %v", got, tt.want) 63 | } 64 | }) 65 | } 66 | } 67 | 68 | func TestOIDCClaims_getClaimAsStringSlice(t *testing.T) { 69 | t.Parallel() 70 | 71 | tests := []struct { 72 | name string 73 | claimValue interface{} 74 | claimArg string 75 | want []string 76 | wantErr bool 77 | }{ 78 | { 79 | name: "string slice", 80 | claimValue: []string{"oneString", "anotherOne"}, 81 | claimArg: "testClaim", 82 | want: []string{"oneString", "anotherOne"}, 83 | wantErr: false, 84 | }, { 85 | name: "single string", 86 | claimValue: "myValue", 87 | claimArg: "testClaim", 88 | want: []string{"myValue"}, 89 | wantErr: false, 90 | }, { 91 | name: "interface slice", 92 | claimValue: []interface{}{"valueOne", "valueTwo"}, 93 | claimArg: "testClaim", 94 | want: []string{"valueOne", "valueTwo"}, 95 | wantErr: false, 96 | }, { 97 | name: "single int", 98 | claimValue: 1, 99 | claimArg: "testClaim", 100 | want: nil, 101 | wantErr: true, 102 | }, { 103 | name: "int slice", 104 | claimValue: []int{1, 2, 3}, 105 | claimArg: "testClaim", 106 | want: nil, 107 | wantErr: true, 108 | }, 109 | } 110 | for _, tt := range tests { 111 | tt := tt 112 | t.Run(tt.name, func(t *testing.T) { 113 | token := jwt.New() 114 | err := token.Set(tt.claimArg, tt.claimValue) 115 | if err != nil { 116 | t.Errorf("Error preparing test: %v", err) 117 | } 118 | stdToken := Token{ 119 | jwtToken: token, 120 | } 121 | got, err := stdToken.GetClaimAsStringSlice(tt.claimArg) 122 | if (err != nil) != tt.wantErr { 123 | t.Errorf("GetClaimAsStringSlice() error = %v, wantErr %v", err, tt.wantErr) 124 | return 125 | } 126 | if !reflect.DeepEqual(got, tt.want) { 127 | t.Errorf("GetClaimAsStringSlice() got = %v, want %v", got, tt.want) 128 | } 129 | }) 130 | } 131 | } 132 | 133 | func TestOIDCClaims_getAllClaimsAsMap(t *testing.T) { 134 | t.Parallel() 135 | 136 | token, err := NewToken("eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3RLZXkiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOlsiY2xpZW50aWQiXSwiZW1haWwiOiJmb29AYmFyLm9yZyIsImV4cCI6MTYyMDA5MjI1MSwiZmFtaWx5X25hbWUiOiJCYXIiLCJnaXZlbl9uYW1lIjoiRm9vIiwiaWFzLWFkbWluIjoidHJ1ZSIsImlhdCI6MTYxOTc5MjI1MSwiaXNzIjoiaHR0cHM6Ly8xMjcuMC4wLjE6NTQ0ODIiLCJqdGkiOiI4NjI3NGE1Ny01N2FlLTQ5NDktOWRjOC03ODY0NjcyOWYzYmMiLCJuYmYiOjE2MTk3OTIyNTEsInVzZXJfdXVpZCI6IjIyMjIyMjIyLTMzMzMtNDQ0NC01NTU1LTY2NjY2NjY2NjY2NiIsInpvbmVfdXVpZCI6IjExMTExMTExLTIyMjItMzMzMy00NDQ0LTg4ODg4ODg4ODg4OCJ9.W-Owtad1oybqDI3tsJYGIIZPXBz2IdKOFoMCp07mv8kBNNVWNL0FbRIwilqU-cry_m-DA__5dKaVwaNW7q_6nCmIdvfmqdDJGCd6836AU4VC18uylSKMwVrm7o3TZsS04dDCjR5pnrSR2tzr-3VrMECRK7YSW4tuAaQC8XDWEnVIxz_l7eIB3v09SeRXi3iiqiYTUTyP3o5EU2Ae1tjYSfgLvOmkHTV406Rp5oaiZZV-jdMq7w-JaD-9JLon8O3XRdTApiYJ6yI9sXLcBrElHzy8M2HKm4FvOb66cJYT4GtB8Ntoq7XQKor0oW5dPPXuEBIl77Hz6PgNa7WYKkBi_w") 137 | if err != nil { 138 | t.Errorf("Error while preparing test: %v", err) 139 | } 140 | 141 | got := token.GetAllClaimsAsMap() 142 | if len(got) != 12 { 143 | t.Errorf("GetAllClaimsAsMap() number of attributes got = %v, want %v", len(got), 12) 144 | } 145 | } 146 | 147 | func TestOIDCClaims_getClaimAsMap(t *testing.T) { 148 | t.Parallel() 149 | 150 | token, err := NewToken("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbmYiOnsieDV0I1MyNTYiOiIwX3daeG5EUXd6dkxqLWh0NHNZbFQ3RzBIMURuT2ZPUC02MGFxeU1PVDI4IiwicHJvb2Z0b2tlbiI6InRydWUifX0.3Xi2fe-m-6lc1Ze9_AsnNpkYAG-LKFPHCld5EggQTW4") 151 | require.NoError(t, err, "Error while preparing test: %v", err) 152 | 153 | got, err := token.GetClaimAsMap(claimCnf) 154 | if err != nil { 155 | t.Errorf("GetClaimAsStringSlice() error = %v", err) 156 | return 157 | } 158 | if len(got) != 2 { 159 | t.Errorf("GetClaimAsMap() number of members got = %v, want %v", len(got), 2) 160 | } 161 | cnfClaimMemberX5t := token.getCnfClaimMember(claimCnfMemberX5t) 162 | if cnfClaimMemberX5t != "0_wZxnDQwzvLj-ht4sYlT7G0H1DnOfOP-60aqyMOT28" { 163 | t.Errorf("getCnfClaimMember()[%v] got = %v", claimCnfMemberX5t, cnfClaimMemberX5t) 164 | } 165 | } 166 | 167 | func TestOIDCClaims_getSAPIssuer(t *testing.T) { 168 | t.Parallel() 169 | 170 | tests := []struct { 171 | name string 172 | iss string 173 | iasIss string 174 | WantCustomIss string 175 | wantIss string 176 | }{ 177 | { 178 | name: "iss claim only", 179 | iss: "http://localhost:3030", 180 | wantIss: "http://localhost:3030", 181 | WantCustomIss: "", 182 | }, 183 | { 184 | name: "iss and ias_iss claim", 185 | iss: "http://localhost:3030", 186 | iasIss: "https://custom.oidc-server.com", 187 | wantIss: "https://custom.oidc-server.com", 188 | WantCustomIss: "http://localhost:3030", 189 | }, 190 | } 191 | for _, tt := range tests { 192 | tt := tt 193 | t.Run(tt.name, func(t *testing.T) { 194 | token, err := NewToken("eyJhbGciOiJIUzI1NiJ9.e30.ZRrHA1JJJW8opsbCGfG_HACGpVUMN_a9IV7pAx_Zmeo") 195 | require.NoError(t, err, "error creating test token") 196 | jwtToken := token.getJwtToken() 197 | _ = jwtToken.Set("iss", tt.iss) 198 | if tt.iasIss != "" { 199 | _ = jwtToken.Set("ias_iss", tt.iasIss) 200 | } 201 | if err != nil { 202 | t.Errorf("Error while preparing test: %v", err) 203 | } 204 | issuerActual := token.CustomIssuer() 205 | if issuerActual != tt.WantCustomIss { 206 | t.Errorf("CustomIssuer() got = %v, want %v", issuerActual, tt.WantCustomIss) 207 | } 208 | iasIssuerActual := token.Issuer() 209 | if iasIssuerActual != tt.wantIss { 210 | t.Errorf("Issuer() got = %v, want %v", iasIssuerActual, tt.wantIss) 211 | } 212 | }) 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /auth/validator.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package auth 6 | 7 | import ( 8 | "errors" 9 | "fmt" 10 | "regexp" 11 | "strings" 12 | "time" 13 | 14 | "github.com/lestrrat-go/jwx/jws" 15 | "github.com/lestrrat-go/jwx/jwt" 16 | 17 | "github.com/sap/cloud-security-client-go/oidcclient" 18 | ) 19 | 20 | // ParseAndValidateJWT parses the token into its claims, verifies the claims and verifies the signature 21 | func (m *Middleware) ParseAndValidateJWT(rawToken string) (Token, error) { 22 | token, err := NewToken(rawToken) 23 | if err != nil { 24 | return Token{}, err 25 | } 26 | 27 | // get keyset 28 | keySet, err := m.getOIDCTenant(token.Issuer(), token.CustomIssuer()) 29 | if err != nil { 30 | return Token{}, err 31 | } 32 | 33 | // verify claims 34 | if err := m.validateClaims(token, keySet); err != nil { 35 | return Token{}, err 36 | } 37 | 38 | // verify signature 39 | if err := m.verifySignature(token, keySet); err != nil { 40 | return Token{}, err 41 | } 42 | 43 | return token, nil 44 | } 45 | 46 | func (m *Middleware) verifySignature(t Token, keySet *oidcclient.OIDCTenant) (err error) { 47 | headers, err := getHeaders(t.TokenValue()) 48 | if err != nil { 49 | return err 50 | } 51 | alg := headers.Algorithm() 52 | 53 | // fail early to avoid another parsing of encoded token 54 | if alg == "" { 55 | return errors.New("alg is missing from jwt header") 56 | } 57 | 58 | // parse and verify signature 59 | tenantOpts := oidcclient.ClientInfo{ 60 | ClientID: m.identity.GetClientID(), 61 | AppTID: t.AppTID(), 62 | Azp: t.Azp(), 63 | } 64 | jwks, err := keySet.GetJWKs(tenantOpts) 65 | if err != nil { 66 | return err 67 | } 68 | _, err = jwt.ParseString(t.TokenValue(), jwt.WithKeySet(jwks), jwt.UseDefaultKey(true)) 69 | if err != nil { 70 | return err 71 | } 72 | return nil 73 | } 74 | 75 | func getHeaders(encodedToken string) (jws.Headers, error) { 76 | msg, err := jws.Parse([]byte(encodedToken)) 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | return msg.Signatures()[0].ProtectedHeaders(), nil 82 | } 83 | 84 | func (m *Middleware) validateClaims(t Token, ks *oidcclient.OIDCTenant) error { // performing IsExpired check, because dgriljalva jwt.Validate() doesn't fail on missing 'exp' claim 85 | // performing IsExpired check, because lestrrat-go jwt.Validate() doesn't fail on missing 'exp' claim 86 | if t.IsExpired() { 87 | return fmt.Errorf("token is expired, exp: %v", t.Expiration()) 88 | } 89 | err := jwt.Validate(t.getJwtToken(), 90 | jwt.WithAudience(m.identity.GetClientID()), 91 | jwt.WithIssuer(ks.ProviderJSON.Issuer), 92 | jwt.WithAcceptableSkew(1*time.Minute)) // to keep leeway in sync with Token.IsExpired 93 | 94 | if err != nil { 95 | return fmt.Errorf("claim validation failed: %v", err) 96 | } 97 | return nil 98 | } 99 | 100 | // getOIDCTenant returns an OIDC Tenant with discovered .well-known/openid-configuration. 101 | // 102 | // issuer is the trusted ias issuer with SAP domain of the incoming token (token.Issuer()) 103 | // 104 | // customIssuer represents the custom issuer of the incoming token if given (token.CustomIssuer()) 105 | func (m *Middleware) getOIDCTenant(issuer, customIssuer string) (*oidcclient.OIDCTenant, error) { 106 | issHost, err := m.verifyIssuer(issuer) 107 | if err != nil { 108 | return nil, err 109 | } 110 | 111 | tokenIssuer := customIssuer 112 | if customIssuer == "" { 113 | tokenIssuer = issuer 114 | } 115 | 116 | oidcTenant, exp, found := m.oidcTenants.GetWithExpiration(issuer) 117 | // redo discovery if not found, cache expired, or tokenIssuer is not the same as Issuer on providerJSON (e.g. custom domain config just changed for that tenant) 118 | if !found || time.Now().After(exp) || oidcTenant.(*oidcclient.OIDCTenant).ProviderJSON.Issuer != tokenIssuer { 119 | newKeySet, err, _ := m.sf.Do(issuer, func() (i interface{}, err error) { 120 | set, err := oidcclient.NewOIDCTenant(m.options.HTTPClient, issHost) 121 | return set, err 122 | }) 123 | 124 | if err != nil { 125 | return nil, fmt.Errorf("token is unverifiable: unable to perform oidc discovery: %v", err) 126 | } 127 | oidcTenant = newKeySet.(*oidcclient.OIDCTenant) 128 | m.oidcTenants.SetDefault(oidcTenant.(*oidcclient.OIDCTenant).ProviderJSON.Issuer, oidcTenant) 129 | } 130 | return oidcTenant.(*oidcclient.OIDCTenant), nil 131 | } 132 | 133 | func (m *Middleware) verifyIssuer(issuer string) (issuerHost string, err error) { 134 | // issuer must be a host or https url 135 | issuerHost = strings.TrimPrefix(issuer, "https://") 136 | 137 | doesMatch, err := matchesDomain(issuerHost, m.identity.GetDomains()) 138 | if err != nil { 139 | return "", fmt.Errorf("error matching domain: %v", err) 140 | } 141 | if !doesMatch { 142 | return "", fmt.Errorf("token is unverifiable: unknown server (domain doesn't match)") 143 | } 144 | 145 | return issuerHost, nil 146 | } 147 | 148 | func matchesDomain(hostname string, domains []string) (bool, error) { 149 | for _, domain := range domains { 150 | if !strings.HasSuffix(hostname, domain) { 151 | continue 152 | } 153 | // hostname matches exactly trusted domain 154 | if hostname == domain { 155 | return true, nil 156 | } 157 | isValid, regexErr := isValidSubDomain(hostname, domain) 158 | if regexErr != nil { 159 | return false, regexErr 160 | } 161 | if isValid { 162 | return true, nil 163 | } 164 | } 165 | return false, nil 166 | } 167 | 168 | // isValidSubDomain additionally check subdomain because "my-accounts400.ondemand.com" 169 | // does match Suffix, but should not be allowed 170 | // additionally it returns false if hostname contains paths like /foo or ?test=true 171 | func isValidSubDomain(hostname, domain string) (bool, error) { 172 | validSubdomainPattern := "^[a-zA-Z0-9-]{1,63}\\." + regexp.QuoteMeta(domain) + "$" 173 | return regexp.MatchString(validSubdomainPattern, hostname) 174 | } 175 | -------------------------------------------------------------------------------- /auth/validator_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package auth 6 | 7 | import ( 8 | "strings" 9 | "sync" 10 | "testing" 11 | "time" 12 | 13 | "github.com/sap/cloud-security-client-go/env" 14 | "github.com/sap/cloud-security-client-go/mocks" 15 | "github.com/stretchr/testify/assert" 16 | ) 17 | 18 | func TestAdditionalDomain(t *testing.T) { 19 | oidcMockServer, err := mocks.NewOIDCMockServer() 20 | if err != nil { 21 | t.Errorf("error creating test setup: %v", err) 22 | } 23 | m := NewMiddleware(env.DefaultIdentity{ 24 | ClientID: oidcMockServer.Config.ClientID, 25 | ClientSecret: oidcMockServer.Config.ClientSecret, 26 | URL: oidcMockServer.Config.URL, 27 | Domains: append([]string{"my.primary.domain"}, oidcMockServer.Config.Domains...), 28 | }, Options{ 29 | HTTPClient: oidcMockServer.Server.Client(), 30 | }) 31 | 32 | rawToken, err := oidcMockServer.SignToken(oidcMockServer.DefaultClaims(), oidcMockServer.DefaultHeaders()) 33 | if err != nil { 34 | t.Errorf("unable to sign provided test token: %v", err) 35 | } 36 | 37 | _, err = m.ParseAndValidateJWT(rawToken) 38 | if err != nil { 39 | t.Error("unexpected error: ", err.Error()) 40 | } 41 | } 42 | 43 | func TestAuthMiddleware_getOIDCTenant(t *testing.T) { 44 | oidcMockServer, err := mocks.NewOIDCMockServer() 45 | if err != nil { 46 | t.Errorf("error creating test setup: %v", err) 47 | } 48 | m := NewMiddleware(env.DefaultIdentity{ 49 | ClientID: oidcMockServer.Config.ClientID, 50 | ClientSecret: oidcMockServer.Config.ClientSecret, 51 | URL: oidcMockServer.Config.URL, 52 | Domains: oidcMockServer.Config.Domains, 53 | }, Options{ 54 | HTTPClient: oidcMockServer.Server.Client(), 55 | }) 56 | 57 | rawToken, err := oidcMockServer.SignToken(oidcMockServer.DefaultClaims(), oidcMockServer.DefaultHeaders()) 58 | if err != nil { 59 | t.Errorf("unable to sign provided test token: %v", err) 60 | } 61 | 62 | token, err := m.ParseAndValidateJWT(rawToken) 63 | if err != nil { 64 | t.Errorf("unable to parse provided test token: %v", err) 65 | } 66 | 67 | concurrentRuns := 5 68 | var wg sync.WaitGroup 69 | wg.Add(concurrentRuns) 70 | 71 | for i := 0; i < concurrentRuns; i++ { 72 | go func(i int) { 73 | defer wg.Done() 74 | 75 | set, err := m.getOIDCTenant(token.Issuer(), token.CustomIssuer()) 76 | if err != nil || set == nil { 77 | t.Errorf("unexpected error on getOIDCTenant(), %v", err) 78 | } 79 | if set.ProviderJSON.Issuer != oidcMockServer.Server.URL { 80 | t.Errorf("GetOIDCTenant() in iteration %d; got = %s, want: %s", i, set.ProviderJSON.Issuer, oidcMockServer.Server.URL) 81 | } else { 82 | t.Logf("response %d as expected: %s", i, set.ProviderJSON.Issuer) 83 | } 84 | }(i) 85 | } 86 | 87 | waitTimeout(&wg, 5*time.Second) 88 | 89 | if hits := oidcMockServer.WellKnownHitCounter; hits != 1 { 90 | t.Errorf("GetOIDCTenant() /.well-known/openid-configuration endpoint called too often; got = %d, want: 1", hits) 91 | } 92 | } 93 | 94 | func TestVerifyIssuerLocal(t *testing.T) { 95 | m := NewMiddleware(env.DefaultIdentity{ 96 | Domains: []string{"127.0.0.1:52421"}, 97 | }, Options{}) 98 | 99 | // trusted url 100 | _, err := m.verifyIssuer("https://127.0.0.1:52421") 101 | assert.NoError(t, err) 102 | } 103 | 104 | func TestVerifyIssuer(t *testing.T) { 105 | trustedDomain := "accounts400.ondemand.com" 106 | m := NewMiddleware(env.DefaultIdentity{ 107 | Domains: []string{"accounts400.cloud.sap", trustedDomain}, 108 | }, Options{}) 109 | 110 | // exact domain 111 | host, err := m.verifyIssuer("https://" + trustedDomain) 112 | assert.NoError(t, err) 113 | assert.Equal(t, host, trustedDomain) 114 | // trusted url 115 | host, err = m.verifyIssuer("https://test." + trustedDomain) 116 | assert.NoError(t, err) 117 | assert.Equal(t, host, "test."+trustedDomain) 118 | // trusted domain 119 | host, err = m.verifyIssuer("test." + trustedDomain) 120 | assert.NoError(t, err) 121 | assert.Equal(t, host, "test."+trustedDomain) 122 | 123 | // support domains with 1 - 63 characters only 124 | _, err = m.verifyIssuer(strings.Repeat("a", 1) + "." + trustedDomain) 125 | assert.NoError(t, err) 126 | _, err = m.verifyIssuer(strings.Repeat("a", 63) + "." + trustedDomain) 127 | assert.NoError(t, err) 128 | _, err = m.verifyIssuer(strings.Repeat("a", 64) + "." + trustedDomain) 129 | assert.Error(t, err) 130 | 131 | // error when issuer contains tabs or new lines 132 | _, err = m.verifyIssuer("te\tnant." + trustedDomain) 133 | assert.Error(t, err) 134 | _, err = m.verifyIssuer("tenant.accounts400.ond\temand.com") 135 | assert.Error(t, err) 136 | _, err = m.verifyIssuer("te\nnant." + trustedDomain) 137 | assert.Error(t, err) 138 | _, err = m.verifyIssuer("tenant.accounts400.ond\nemand.com") 139 | assert.Error(t, err) 140 | 141 | // error when issuer contains encoded characters 142 | _, err = m.verifyIssuer("https://tenant%2e" + trustedDomain) // %2e instead of . 143 | assert.Error(t, err) 144 | _, err = m.verifyIssuer("https://tenant%2d0815.accounts400.ond\nemand.com") 145 | assert.Error(t, err) 146 | 147 | // empty issuer 148 | _, err = m.verifyIssuer("") 149 | assert.Error(t, err) 150 | // illegal subdomain 151 | _, err = m.verifyIssuer("https://my-" + trustedDomain) 152 | assert.Error(t, err) 153 | 154 | // invalid url 155 | _, err = m.verifyIssuer("https://") 156 | assert.Error(t, err) 157 | 158 | // error if http protocol is used 159 | _, err = m.verifyIssuer("http://" + trustedDomain) 160 | assert.Error(t, err) 161 | 162 | // error when issuer contains more than a valid subdomain of the trusted domains 163 | _, err = m.verifyIssuer("https://" + trustedDomain + "a") 164 | assert.Error(t, err) 165 | _, err = m.verifyIssuer("https://" + trustedDomain + "%2f") 166 | assert.Error(t, err) 167 | _, err = m.verifyIssuer("https://" + trustedDomain + "%2fpath") 168 | assert.Error(t, err) 169 | _, err = m.verifyIssuer("https://" + trustedDomain + "&") 170 | assert.Error(t, err) 171 | _, err = m.verifyIssuer("https://" + trustedDomain + "%26") 172 | assert.Error(t, err) 173 | _, err = m.verifyIssuer("https://" + trustedDomain + "?") 174 | assert.Error(t, err) 175 | _, err = m.verifyIssuer("https://" + trustedDomain + "?foo") 176 | assert.Error(t, err) 177 | _, err = m.verifyIssuer("https://" + trustedDomain + "#") 178 | assert.Error(t, err) 179 | _, err = m.verifyIssuer("https://" + "user@" + trustedDomain) 180 | assert.Error(t, err) 181 | _, err = m.verifyIssuer("https://" + "user%40" + trustedDomain) 182 | assert.Error(t, err) 183 | _, err = m.verifyIssuer("https://" + "tenant!" + trustedDomain) 184 | assert.Error(t, err) 185 | } 186 | 187 | func waitTimeout(wg *sync.WaitGroup, timeout time.Duration) bool { 188 | c := make(chan struct{}) 189 | go func() { 190 | defer close(c) 191 | wg.Wait() 192 | }() 193 | select { 194 | case <-c: 195 | return false // completed normally 196 | case <-time.After(timeout): 197 | return true // timed out 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /env/environment.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020-2021 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package env 6 | 7 | import ( 8 | "os" 9 | "strings" 10 | ) 11 | 12 | // Platform holds the type string of the platform the application runs on 13 | type Platform string 14 | 15 | const ( 16 | cloudFoundry Platform = "CLOUD_FOUNDRY" 17 | kubernetes Platform = "KUBERNETES" 18 | unknown Platform = "UNKNOWN" 19 | ) 20 | 21 | func getPlatform() Platform { 22 | switch { 23 | case strings.TrimSpace(os.Getenv("VCAP_SERVICES")) != "": 24 | return cloudFoundry 25 | case strings.TrimSpace(os.Getenv("KUBERNETES_SERVICE_HOST")) != "": 26 | return kubernetes 27 | default: 28 | return unknown 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /env/iasConfig.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020-2021 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package env 6 | 7 | import ( 8 | "encoding/json" 9 | "fmt" 10 | "os" 11 | "path" 12 | 13 | "github.com/google/uuid" 14 | "gopkg.in/yaml.v3" 15 | ) 16 | 17 | const iasServiceName = "identity" 18 | const iasSecretKeyDefault = "credentials" 19 | const vcapServicesEnvKey = "VCAP_SERVICES" 20 | const iasConfigPathKey = "IAS_CONFIG_PATH" 21 | const iasConfigPathDefault = "/etc/secrets/sapbtp/identity" 22 | 23 | // VCAPServices is the Cloud Foundry environment variable that stores information about services bound to the application 24 | type VCAPServices struct { 25 | Identity []struct { 26 | Credentials DefaultIdentity `json:"credentials"` 27 | } `json:"identity"` 28 | } 29 | 30 | // Identity interface has to be implemented to instantiate NewMiddleware. For IAS the standard implementation IASConfig from ../env/iasConfig.go package can be used. 31 | type Identity interface { 32 | GetClientID() string // Returns the client id of the oAuth client. 33 | GetClientSecret() string // Returns the client secret. Optional 34 | GetURL() string // Returns the url to the DefaultIdentity tenant. E.g. https://abcdefgh.accounts.ondemand.com 35 | GetDomains() []string // Returns the domains of the DefaultIdentity service. E.g. ["accounts.ondemand.com"] 36 | GetZoneUUID() uuid.UUID // Deprecated: Returns the zone uuid, will be replaced by GetAppTID Optional 37 | GetAppTID() string // Returns the app tid uuid and replaces zone uuid in future Optional 38 | GetProofTokenURL() string // Returns the proof token url. Optional 39 | GetCertificate() string // Returns the client certificate. Optional 40 | GetKey() string // Returns the client certificate key. Optional 41 | GetCertificateExpiresAt() string // Returns the client certificate expiration time. Optional 42 | IsCertificateBased() bool // Returns true, in case GetCertificate() and GetKey returns non-empty values 43 | GetAuthorizationInstanceID() string // Returns the AMS instance id if authorization is enabled 44 | GetAuthorizationBundleURL() string // Returns the AMS Bundle URL if authorization is enabled 45 | } 46 | 47 | // DefaultIdentity represents the parsed credentials from the ias binding 48 | type DefaultIdentity struct { 49 | ClientID string `json:"clientid"` 50 | ClientSecret string `json:"clientsecret"` 51 | Domains []string `json:"domains"` 52 | URL string `json:"url"` 53 | ZoneUUID uuid.UUID `json:"zone_uuid"` // Deprecated: will be replaced by AppTID 54 | AppTID string `json:"app_tid"` // replaces ZoneUUID 55 | ProofTokenURL string `json:"prooftoken_url"` 56 | OsbURL string `json:"osb_url"` 57 | Certificate string `json:"certificate"` 58 | Key string `json:"key"` 59 | CertificateExpiresAt string `json:"certificate_expires_at"` 60 | AuthorizationInstanceID string `json:"authorization_instance_id"` 61 | AuthorizationBundleURL string `json:"authorization_bundle_url"` 62 | } 63 | 64 | // ParseIdentityConfig parses the IAS config from the applications environment 65 | func ParseIdentityConfig() (Identity, error) { 66 | switch getPlatform() { //nolint:exhaustive // Unknown case is handled by default 67 | case cloudFoundry: 68 | var vcapServices VCAPServices 69 | vcapServicesString := os.Getenv(vcapServicesEnvKey) 70 | err := json.Unmarshal([]byte(vcapServicesString), &vcapServices) 71 | if err != nil { 72 | return nil, fmt.Errorf("cannot parse vcap services: %w", err) 73 | } 74 | if len(vcapServices.Identity) == 0 { 75 | return nil, fmt.Errorf("no '%s' service instance bound to the application", iasServiceName) 76 | } 77 | if len(vcapServices.Identity) > 1 { 78 | return nil, fmt.Errorf("more than one '%s' service instance bound to the application. This is currently not supported", iasServiceName) 79 | } 80 | return &vcapServices.Identity[0].Credentials, nil 81 | case kubernetes: 82 | var secretPath = os.Getenv(iasConfigPathKey) 83 | if secretPath == "" { 84 | secretPath = iasConfigPathDefault 85 | } 86 | identities, err := readServiceBindings(secretPath) 87 | if err != nil || len(identities) == 0 { 88 | return nil, fmt.Errorf("cannot find '%s' service binding from secret path '%s'", iasServiceName, secretPath) 89 | } else if len(identities) > 1 { 90 | return nil, fmt.Errorf("found more than one '%s' service instance from secret path '%s'. This is currently not supported", iasServiceName, secretPath) 91 | } 92 | return &identities[0], nil 93 | default: 94 | return nil, fmt.Errorf("unable to parse '%s' service config: unknown environment detected", iasServiceName) 95 | } 96 | } 97 | 98 | func readServiceBindings(secretPath string) ([]DefaultIdentity, error) { 99 | instancesBound, err := os.ReadDir(secretPath) 100 | if err != nil { 101 | return nil, fmt.Errorf("cannot read service directory '%s' for identity service: %w", secretPath, err) 102 | } 103 | identities := []DefaultIdentity{} 104 | for _, instanceBound := range instancesBound { 105 | if !instanceBound.IsDir() { 106 | continue 107 | } 108 | serviceInstancePath := path.Join(secretPath, instanceBound.Name()) 109 | instanceSecretFiles, err := os.ReadDir(serviceInstancePath) 110 | if err != nil { 111 | return nil, fmt.Errorf("cannot read service instance directory '%s' for '%s' service instance '%s': %w", serviceInstancePath, iasServiceName, instanceBound.Name(), err) 112 | } 113 | instanceSecretsJSON, err := readCredentialsFileToJSON(serviceInstancePath, instanceSecretFiles) 114 | if instanceSecretsJSON == nil || err != nil { 115 | instanceSecretsJSON, err = readSecretFilesToJSON(serviceInstancePath, instanceSecretFiles) 116 | if err != nil { 117 | return nil, err 118 | } 119 | } 120 | identity := DefaultIdentity{} 121 | if err := json.Unmarshal(instanceSecretsJSON, &identity); err != nil { 122 | return nil, fmt.Errorf("cannot unmarshal json content in directory '%s' for '%s' service instance: %w", serviceInstancePath, iasServiceName, err) 123 | } 124 | identities = append(identities, identity) 125 | } 126 | return identities, nil 127 | } 128 | 129 | func readCredentialsFileToJSON(serviceInstancePath string, instanceSecretFiles []os.DirEntry) ([]byte, error) { 130 | for _, instanceSecretFile := range instanceSecretFiles { 131 | if !instanceSecretFile.IsDir() && instanceSecretFile.Name() == iasSecretKeyDefault { 132 | serviceInstanceCredentialsPath := path.Join(serviceInstancePath, instanceSecretFile.Name()) 133 | credentials, err := os.ReadFile(serviceInstanceCredentialsPath) 134 | if err != nil { 135 | return nil, fmt.Errorf("cannot read content from '%s': %w", serviceInstanceCredentialsPath, err) 136 | } 137 | if json.Valid(credentials) { 138 | return credentials, nil 139 | } 140 | } 141 | } 142 | return nil, nil 143 | } 144 | 145 | func readSecretFilesToJSON(serviceInstancePath string, instanceSecretFiles []os.DirEntry) ([]byte, error) { 146 | instanceCredentialsMap := make(map[string]interface{}) 147 | for _, instanceSecretFile := range instanceSecretFiles { 148 | if instanceSecretFile.IsDir() { 149 | continue 150 | } 151 | serviceInstanceSecretPath := path.Join(serviceInstancePath, instanceSecretFile.Name()) 152 | var secretContent []byte 153 | secretContent, err := os.ReadFile(serviceInstanceSecretPath) 154 | if err != nil { 155 | return nil, fmt.Errorf("cannot read secret file '%s' from '%s': %w", instanceSecretFile.Name(), serviceInstanceSecretPath, err) 156 | } 157 | var v interface{} 158 | if err := yaml.Unmarshal(secretContent, &v); err == nil { 159 | instanceCredentialsMap[instanceSecretFile.Name()] = v 160 | } else { 161 | fmt.Printf("cannot unmarshal content of secret file '%s' from '%s': %s", instanceSecretFile.Name(), serviceInstanceSecretPath, err) 162 | instanceCredentialsMap[instanceSecretFile.Name()] = string(secretContent) 163 | } 164 | } 165 | instanceCredentialsJSON, err := json.Marshal(instanceCredentialsMap) 166 | if err != nil { 167 | return nil, fmt.Errorf("cannot marshal map into json: %w", err) 168 | } 169 | return instanceCredentialsJSON, nil 170 | } 171 | 172 | // GetClientID implements the env.Identity interface. 173 | func (c DefaultIdentity) GetClientID() string { 174 | return c.ClientID 175 | } 176 | 177 | // GetClientSecret implements the env.Identity interface. 178 | func (c DefaultIdentity) GetClientSecret() string { 179 | return c.ClientSecret 180 | } 181 | 182 | // GetURL implements the env.Identity interface. 183 | func (c DefaultIdentity) GetURL() string { 184 | return c.URL 185 | } 186 | 187 | // GetDomains implements the env.Identity interface. 188 | func (c DefaultIdentity) GetDomains() []string { 189 | return c.Domains 190 | } 191 | 192 | // GetZoneUUID implements the env.Identity interface. 193 | // Deprecated: is replaced by GetAppTID and will be removed with the next major release 194 | func (c DefaultIdentity) GetZoneUUID() uuid.UUID { 195 | appTid, err := uuid.Parse(c.AppTID) 196 | if err == nil { 197 | return appTid 198 | } 199 | return c.ZoneUUID 200 | } 201 | 202 | // GetAppTID implements the env.Identity interface and replaces GetZoneUUID in future 203 | func (c DefaultIdentity) GetAppTID() string { 204 | if c.AppTID != "" { 205 | return c.AppTID 206 | } 207 | return c.ZoneUUID.String() 208 | } 209 | 210 | // GetProofTokenURL implements the env.Identity interface. 211 | func (c DefaultIdentity) GetProofTokenURL() string { 212 | return c.ProofTokenURL 213 | } 214 | 215 | // GetOsbURL implements the env.Identity interface. 216 | func (c DefaultIdentity) GetOsbURL() string { 217 | return c.OsbURL 218 | } 219 | 220 | // GetCertificate implements the env.Identity interface. 221 | func (c DefaultIdentity) GetCertificate() string { 222 | return c.Certificate 223 | } 224 | 225 | // IsCertificateBased implements the env.Identity interface. 226 | func (c DefaultIdentity) IsCertificateBased() bool { 227 | return c.Certificate != "" && c.Key != "" 228 | } 229 | 230 | // GetKey implements the env.Identity interface. 231 | func (c DefaultIdentity) GetKey() string { 232 | return c.Key 233 | } 234 | 235 | // GetCertificateExpiresAt implements the env.Identity interface. 236 | func (c DefaultIdentity) GetCertificateExpiresAt() string { 237 | return c.CertificateExpiresAt 238 | } 239 | 240 | // GetAuthorizationInstanceID implements the env.Identity interface. 241 | func (c DefaultIdentity) GetAuthorizationInstanceID() string { 242 | return c.AuthorizationInstanceID 243 | } 244 | 245 | // GetAuthorizationBundleURL implements the env.Identity interface. 246 | func (c DefaultIdentity) GetAuthorizationBundleURL() string { 247 | return c.AuthorizationBundleURL 248 | } 249 | -------------------------------------------------------------------------------- /env/iasConfig_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020-2021 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package env 6 | 7 | import ( 8 | "path" 9 | "reflect" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | var testConfig = &DefaultIdentity{ 16 | ClientID: "cef76757-de57-480f-be92-1d8c1c7abf16", 17 | ClientSecret: "[the_CLIENT.secret:3[/abc", 18 | Domains: []string{"accounts400.ondemand.com", "my.arbitrary.domain"}, 19 | URL: "https://mytenant.accounts400.ondemand.com", 20 | AppTID: "70cd0de3-528a-4655-b56a-5862591def5c", 21 | AuthorizationInstanceID: "8d5423d7-bda4-461c-9670-1b9adc142f0a", 22 | AuthorizationBundleURL: "https://mytenant.accounts400.ondemand.com/sap/ams/v1/bundles", 23 | } 24 | 25 | func TestParseIdentityConfig(t *testing.T) { 26 | tests := []struct { 27 | name string 28 | k8sSecretPath string 29 | env string 30 | want Identity 31 | wantErr bool 32 | }{ 33 | { 34 | name: "[CF] single identity service instance bound", 35 | env: `{"identity":[{"binding_name":null,"credentials":{"clientid":"cef76757-de57-480f-be92-1d8c1c7abf16","clientsecret":"[the_CLIENT.secret:3[/abc","domains":["accounts400.ondemand.com","my.arbitrary.domain"],"token_url":"https://mytenant.accounts400.ondemand.com/oauth2/token","url":"https://mytenant.accounts400.ondemand.com", "app_tid":"70cd0de3-528a-4655-b56a-5862591def5c", "authorization_instance_id":"8d5423d7-bda4-461c-9670-1b9adc142f0a", "authorization_bundle_url":"https://mytenant.accounts400.ondemand.com/sap/ams/v1/bundles"},"instance_name":"my-ams-instance","label":"identity","name":"my-ams-instance","plan":"application","provider":null,"syslog_drain_url":null,"tags":["ias"],"volume_mounts":[]}]}`, 36 | want: testConfig, 37 | wantErr: false, 38 | }, 39 | { 40 | name: "[CF] multiple identity service bindings", 41 | env: `{"identity":[{"binding_name":null,"credentials":{"clientid":"cef76757-de57-480f-be92-1d8c1c7abf16","clientsecret":"[the_CLIENT.secret:3[/abc","domains":["accounts400.ondemand.com","my.arbitrary.domain"],"token_url":"https://mytenant.accounts400.ondemand.com/oauth2/token","url":"https://mytenant.accounts400.ondemand.com"},"instance_name":"my-ams-instance","label":"identity","name":"my-ams-instance","plan":"application","provider":null,"syslog_drain_url":null,"tags":["ias"],"volume_mounts":[]},{"binding_name":null,"credentials":{"clientid":"cef76757-de57-480f-be92-1d8c1c7abf16","clientsecret":"the_CLIENT.secret:3[/abc","domain":"accounts400.ondemand.com","token_url":"https://mytenant.accounts400.ondemand.com/oauth2/token","url":"https://mytenant.accounts400.ondemand.com"},"instance_name":"my-ams-instance","label":"identity","name":"my-ams-instance","plan":"application","provider":null,"syslog_drain_url":null,"tags":["ias"],"volume_mounts":[]}]}`, 42 | want: nil, 43 | wantErr: true, 44 | }, 45 | { 46 | name: "[CF] no identity service binding", 47 | env: "{}", 48 | want: nil, 49 | wantErr: true, 50 | }, 51 | { 52 | name: "[K8s] single identity service instance bound", 53 | k8sSecretPath: path.Join("testdata", "k8s", "single-instance"), 54 | want: testConfig, 55 | wantErr: false, 56 | }, 57 | { 58 | name: "[K8s] no bindings on default secret path", 59 | k8sSecretPath: "ignore", 60 | want: nil, 61 | wantErr: true, 62 | }, 63 | { 64 | name: "[K8s] multiple identity service bindings", 65 | k8sSecretPath: path.Join("testdata", "k8s", "multi-instances"), 66 | want: nil, 67 | wantErr: true, 68 | }, 69 | { 70 | name: "[K8s] single identity service instance bound with secretKey=credentials", 71 | k8sSecretPath: path.Join("testdata", "k8s", "single-instance-onecredentialsfile"), 72 | want: testConfig, 73 | wantErr: false, 74 | }, 75 | } 76 | 77 | for _, tt := range tests { 78 | t.Run(tt.name, func(t *testing.T) { 79 | var err error 80 | if tt.env != "" { 81 | setTestEnv(t, tt.env) 82 | } else if tt.k8sSecretPath != "" { 83 | setK8sTestEnv(t, tt.k8sSecretPath) 84 | } 85 | if err != nil { 86 | t.Error(err) 87 | } 88 | got, err := ParseIdentityConfig() 89 | if err != nil { 90 | if !tt.wantErr { 91 | t.Errorf("ParseIdentityConfig() error = %v, wantErr:%v", err, tt.wantErr) 92 | return 93 | } 94 | t.Logf("ParseIdentityConfig() error = %v, wantErr:%v", err, tt.wantErr) 95 | } 96 | if !reflect.DeepEqual(got, tt.want) { 97 | t.Errorf("ParseIdentityConfig() got = %v, want %v", got, tt.want) 98 | } 99 | if tt.want != nil { 100 | assert.False(t, tt.want.IsCertificateBased()) 101 | } 102 | }) 103 | } 104 | } 105 | 106 | func TestX509BasedCredentials(t *testing.T) { 107 | setTestEnv(t, `{"identity":[{"credentials":{"clientid":"cef76757-de57-480f-be92-1d8c1c7abf16","certificate":"theCertificate","key":"thekey","app_tid":"70cd0de3-528a-4655-b56a-5862591def5c"}}]}`) 108 | got, err := ParseIdentityConfig() 109 | assert.NoError(t, err) 110 | assert.Equal(t, got.GetClientID(), "cef76757-de57-480f-be92-1d8c1c7abf16") 111 | assert.Equal(t, got.GetCertificate(), "theCertificate") 112 | assert.Equal(t, got.GetKey(), "thekey") 113 | assert.Equal(t, got.GetZoneUUID().String(), "70cd0de3-528a-4655-b56a-5862591def5c") 114 | assert.True(t, got.IsCertificateBased()) 115 | } 116 | 117 | // Cleanup when go 1.18 is released 118 | func setTestEnv(t *testing.T, vcapServices string) { 119 | t.Setenv("VCAP_SERVICES", vcapServices) 120 | t.Setenv("VCAP_APPLICATION", "{}") 121 | } 122 | 123 | func setK8sTestEnv(t *testing.T, secretPath string) { 124 | t.Setenv("KUBERNETES_SERVICE_HOST", "0.0.0.0") 125 | if secretPath != "" && secretPath != "ignore" { 126 | t.Setenv("IAS_CONFIG_PATH", secretPath) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /env/testdata/k8s/multi-instances/service-instance-1/clientid: -------------------------------------------------------------------------------- 1 | cef76757-de57-480f-be92-1d8c1c7abf16 -------------------------------------------------------------------------------- /env/testdata/k8s/multi-instances/service-instance-2/clientid: -------------------------------------------------------------------------------- 1 | cef76757-de57-480f-be92-1d8c1c7abf16 -------------------------------------------------------------------------------- /env/testdata/k8s/single-instance-onecredentialsfile/service-instance/credentials: -------------------------------------------------------------------------------- 1 | { 2 | "clientsecret": "[the_CLIENT.secret:3[/abc", 3 | "clientid": "cef76757-de57-480f-be92-1d8c1c7abf16", 4 | "domains": [ 5 | "accounts400.ondemand.com", "my.arbitrary.domain" 6 | ], 7 | "url": "https://mytenant.accounts400.ondemand.com", 8 | "app_tid": "70cd0de3-528a-4655-b56a-5862591def5c", 9 | "authorization_instance_id": "8d5423d7-bda4-461c-9670-1b9adc142f0a", 10 | "authorization_bundle_url": "https://mytenant.accounts400.ondemand.com/sap/ams/v1/bundles" 11 | } -------------------------------------------------------------------------------- /env/testdata/k8s/single-instance-onecredentialsfile/service-instance/ignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SAP/cloud-security-client-go/de56f535457d59c08d5e42edc39546e744accf9b/env/testdata/k8s/single-instance-onecredentialsfile/service-instance/ignore -------------------------------------------------------------------------------- /env/testdata/k8s/single-instance/service-instance/app_tid: -------------------------------------------------------------------------------- 1 | 70cd0de3-528a-4655-b56a-5862591def5c -------------------------------------------------------------------------------- /env/testdata/k8s/single-instance/service-instance/authorization_bundle_url: -------------------------------------------------------------------------------- 1 | https://mytenant.accounts400.ondemand.com/sap/ams/v1/bundles -------------------------------------------------------------------------------- /env/testdata/k8s/single-instance/service-instance/authorization_instance_id: -------------------------------------------------------------------------------- 1 | 8d5423d7-bda4-461c-9670-1b9adc142f0a -------------------------------------------------------------------------------- /env/testdata/k8s/single-instance/service-instance/clientid: -------------------------------------------------------------------------------- 1 | cef76757-de57-480f-be92-1d8c1c7abf16 -------------------------------------------------------------------------------- /env/testdata/k8s/single-instance/service-instance/clientsecret: -------------------------------------------------------------------------------- 1 | [the_CLIENT.secret:3[/abc -------------------------------------------------------------------------------- /env/testdata/k8s/single-instance/service-instance/credentials: -------------------------------------------------------------------------------- 1 | These "credentials" content shall be ignored! -------------------------------------------------------------------------------- /env/testdata/k8s/single-instance/service-instance/domains: -------------------------------------------------------------------------------- 1 | ["accounts400.ondemand.com", "my.arbitrary.domain"] -------------------------------------------------------------------------------- /env/testdata/k8s/single-instance/service-instance/ignore/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SAP/cloud-security-client-go/de56f535457d59c08d5e42edc39546e744accf9b/env/testdata/k8s/single-instance/service-instance/ignore/.gitkeep -------------------------------------------------------------------------------- /env/testdata/k8s/single-instance/service-instance/url: -------------------------------------------------------------------------------- 1 | https://mytenant.accounts400.ondemand.com -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/sap/cloud-security-client-go 2 | 3 | go 1.23.0 // should be kept in sync with .github/workflows/build.yml 4 | 5 | require ( 6 | github.com/google/uuid v1.6.0 7 | github.com/gorilla/handlers v1.5.2 8 | github.com/gorilla/mux v1.8.1 9 | github.com/lestrrat-go/jwx v1.2.31 10 | github.com/patrickmn/go-cache v2.1.0+incompatible 11 | github.com/pquerna/cachecontrol v0.2.0 12 | github.com/stretchr/testify v1.10.0 13 | golang.org/x/sync v0.14.0 14 | gopkg.in/yaml.v3 v3.0.1 15 | ) 16 | 17 | require ( 18 | github.com/davecgh/go-spew v1.1.1 // indirect 19 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect 20 | github.com/felixge/httpsnoop v1.0.4 // indirect 21 | github.com/goccy/go-json v0.10.5 // indirect 22 | github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect 23 | github.com/lestrrat-go/blackmagic v1.0.2 // indirect 24 | github.com/lestrrat-go/httpcc v1.0.1 // indirect 25 | github.com/lestrrat-go/iter v1.0.2 // indirect 26 | github.com/lestrrat-go/option v1.0.1 // indirect 27 | github.com/pkg/errors v0.9.1 // indirect 28 | github.com/pmezard/go-difflib v1.0.0 // indirect 29 | golang.org/x/crypto v0.35.0 // indirect 30 | ) 31 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= 5 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= 6 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 7 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 8 | github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= 9 | github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 10 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 11 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 12 | github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= 13 | github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= 14 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 15 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 16 | github.com/lestrrat-go/backoff/v2 v2.0.8 h1:oNb5E5isby2kiro9AgdHLv5N5tint1AnDVVf2E2un5A= 17 | github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y= 18 | github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= 19 | github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= 20 | github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= 21 | github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= 22 | github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= 23 | github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= 24 | github.com/lestrrat-go/jwx v1.2.31 h1:/OM9oNl/fzyldpv5HKZ9m7bTywa7COUfg8gujd9nJ54= 25 | github.com/lestrrat-go/jwx v1.2.31/go.mod h1:eQJKoRwWcLg4PfD5CFA5gIZGxhPgoPYq9pZISdxLf0c= 26 | github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 27 | github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 28 | github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 29 | github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= 30 | github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= 31 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 32 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 33 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 34 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 35 | github.com/pquerna/cachecontrol v0.2.0 h1:vBXSNuE5MYP9IJ5kjsdo8uq+w41jSPgvba2DEnkRx9k= 36 | github.com/pquerna/cachecontrol v0.2.0/go.mod h1:NrUG3Z7Rdu85UNR3vm7SOsl1nFIeSiQnrHV5K9mBcUI= 37 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 38 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 39 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 40 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 41 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 42 | golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= 43 | golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= 44 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 45 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 46 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 47 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 48 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 49 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 50 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 51 | -------------------------------------------------------------------------------- /httpclient/httpclient.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2021 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | package httpclient 5 | 6 | import ( 7 | "context" 8 | "crypto/tls" 9 | "crypto/x509" 10 | "errors" 11 | "fmt" 12 | "io" 13 | "net/http" 14 | "time" 15 | 16 | "github.com/sap/cloud-security-client-go/env" 17 | ) 18 | 19 | const UserAgent = "go-sec-lib" 20 | 21 | // DefaultTLSConfig creates default tls.Config. Initializes SystemCertPool with cert/key from identity config. 22 | // 23 | // identity provides certificate and key 24 | func DefaultTLSConfig(identity env.Identity) (*tls.Config, error) { 25 | if !identity.IsCertificateBased() { 26 | return &tls.Config{ 27 | MinVersion: tls.VersionTLS12, 28 | Renegotiation: tls.RenegotiateOnceAsClient, 29 | }, nil 30 | } 31 | certPEMBlock := []byte(identity.GetCertificate()) 32 | keyPEMBlock := []byte(identity.GetKey()) 33 | 34 | tlsCert, err := tls.X509KeyPair(certPEMBlock, keyPEMBlock) 35 | if err != nil { 36 | return nil, fmt.Errorf("error creating x509 key pair for DefaultTLSConfig: %w", err) 37 | } 38 | tlsCertPool, err := x509.SystemCertPool() 39 | if err != nil { 40 | return nil, fmt.Errorf("error setting up cert pool for DefaultTLSConfig: %w", err) 41 | } 42 | ok := tlsCertPool.AppendCertsFromPEM(certPEMBlock) 43 | if !ok { 44 | return nil, errors.New("error adding certs to pool for DefaultTLSConfig") 45 | } 46 | tlsConfig := &tls.Config{ 47 | MinVersion: tls.VersionTLS12, 48 | RootCAs: tlsCertPool, 49 | Certificates: []tls.Certificate{tlsCert}, 50 | Renegotiation: tls.RenegotiateOnceAsClient, 51 | } 52 | return tlsConfig, nil 53 | } 54 | 55 | // DefaultHTTPClient 56 | // 57 | // tlsConfig required in case of cert-based identity config 58 | func DefaultHTTPClient(tlsConfig *tls.Config) *http.Client { 59 | client := &http.Client{ 60 | Timeout: time.Second * 10, 61 | } 62 | if tlsConfig != nil { 63 | client.Transport = &http.Transport{ 64 | TLSClientConfig: tlsConfig, 65 | MaxIdleConns: 50, 66 | } 67 | } 68 | return client 69 | } 70 | 71 | // NewRequestWithUserAgent creates a request and sets the libs custom user agent 72 | // it would be nicer to set this in the default http.client, but 73 | // it's discouraged to manipulate the request in RoundTrip per official documentation 74 | func NewRequestWithUserAgent(ctx context.Context, method, url string, body io.Reader) (*http.Request, error) { 75 | r, err := http.NewRequestWithContext(ctx, method, url, body) 76 | if err != nil { 77 | return nil, err 78 | } 79 | r.Header.Set("User-Agent", UserAgent) 80 | return r, nil 81 | } 82 | -------------------------------------------------------------------------------- /httpclient/httpclient_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2021 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | package httpclient 5 | 6 | import ( 7 | _ "embed" 8 | "runtime" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/assert" 13 | 14 | "github.com/sap/cloud-security-client-go/env" 15 | ) 16 | 17 | //go:embed testdata/certificate.pem 18 | var certificate string 19 | 20 | //go:embed testdata/privateTestingKey.pem 21 | var dummyKey string 22 | 23 | //go:embed testdata/otherTestingKey.pem 24 | var otherKey string 25 | 26 | var mTLSConfig = &env.DefaultIdentity{ 27 | ClientID: "09932670-9440-445d-be3e-432a97d7e2ef", 28 | Certificate: certificate, 29 | Key: strings.ReplaceAll(dummyKey, "TESTING KEY", "PRIVATE KEY"), 30 | URL: "https://mySaaS.accounts400.ondemand.com", 31 | } 32 | 33 | func TestDefaultTLSConfig_ReturnsNil(t *testing.T) { 34 | tlsConfig, err := DefaultTLSConfig(&env.DefaultIdentity{}) 35 | assert.NoError(t, err) 36 | assert.NotNil(t, tlsConfig) 37 | } 38 | 39 | func TestDefaultHTTPClient_ClientCertificate(t *testing.T) { 40 | if runtime.GOOS == "windows" { 41 | t.Skip("skip test on windows os. Module crypto/x509 supports SystemCertPool with go 1.18 (https://go-review.googlesource.com/c/go/+/353589/)") 42 | } 43 | tlsConfig, err := DefaultTLSConfig(mTLSConfig) 44 | assert.NoError(t, err) 45 | httpsClient := DefaultHTTPClient(tlsConfig) 46 | assert.NotNil(t, httpsClient) 47 | } 48 | 49 | func TestDefaultHTTPClient_ClientCredentials(t *testing.T) { 50 | httpsClient := DefaultHTTPClient(nil) 51 | assert.NotNil(t, httpsClient) 52 | } 53 | 54 | func TestDefaultTLSConfig_shouldFailIfKeyDoesNotMatch(t *testing.T) { 55 | mTLSConfig.Key = strings.ReplaceAll(otherKey, "TESTING KEY", "PRIVATE KEY") 56 | tlsConfig, err := DefaultTLSConfig(mTLSConfig) 57 | assert.Error(t, err) 58 | assert.Nil(t, tlsConfig) 59 | } 60 | -------------------------------------------------------------------------------- /httpclient/testdata/certificate.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIF/DCCA+SgAwIBAgIRAMcQu+gDqqM7hF2y+Kl8lB8wDQYJKoZIhvcNAQELBQAw 3 | gYAxCzAJBgNVBAYTAkRFMRQwEgYDVQQHDAtFVTEwLUNhbmFyeTEPMA0GA1UECgwG 4 | U0FQIFNFMSMwIQYDVQQLDBpTQVAgQ2xvdWQgUGxhdGZvcm0gQ2xpZW50czElMCMG 5 | A1UEAwwcU0FQIENsb3VkIFBsYXRmb3JtIENsaWVudCBDQTAeFw0yMTExMjYwODQ4 6 | MDVaFw0yMTEyMjYwOTQ4MDVaMIHhMQswCQYDVQQGEwJERTEPMA0GA1UEChMGU0FQ 7 | IFNFMSMwIQYDVQQLExpTQVAgQ2xvdWQgUGxhdGZvcm0gQ2xpZW50czEPMA0GA1UE 8 | CxMGQ2FuYXJ5MS0wKwYDVQQLEyQ4ZTFhZmZiMi02MmExLTQzY2MtYTY4Ny0yYmE3 9 | NWU0YjNkODQxKzApBgNVBAcTImFveGsyYWRkaC5hY2NvdW50czQwMC5vbmRlbWFu 10 | ZC5jb20xLzAtBgNVBAMTJmIvZWExMmI5NmYtNmU0YS00MzQ1LTk4ZjAtMjJiZjI4 11 | ZDdhZTZjMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoOJCt0fMMrdP 12 | qcaZhJt3izcNGosjNxy8QS+O2nCL93Li76WlirbBU4DPcu83Bvw09B3JO3fNO0F9 13 | T4qmoEXGBcT8Sxk+jft8Xv2MA3cVv5yRZwgEG3SUsmMCei7sATlG8AnqGC3wqwGs 14 | /yaNS/M2bkldBl1y1ig7ft08z0HmzvyvT+/dWaIJDde0KmKS+fkm3OCiaCOBOJFY 15 | MAyQw3H6AM83QA8wDNcTkdsUlG2PbcXM8GSxQSm0WJpqM9tv7s52hVMHnZCSR5gI 16 | QeQ2PSQBwLyHhUxGphHFaxb/hUUw0ObLIfVItJFrKdIxZmcohAr1O1BfZU118B0C 17 | W5FTqFf9CQIDAQABo4IBDDCCAQgwCQYDVR0TBAIwADAfBgNVHSMEGDAWgBTMIIRb 18 | hxWLN/l+h7HJPsNvlQRukTAdBgNVHQ4EFgQUBr0ZlplKD0uZ4ftT9ZpvEu8cWQ8w 19 | DgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMCMIGVBgNVHR8EgY0w 20 | gYowgYeggYSggYGGf2h0dHA6Ly9zYXAtY2xvdWQtcGxhdGZvcm0tY2xpZW50LWNh 21 | LWV1MTAtY2FuYXJ5LWNybHMuczMuZXUtY2VudHJhbC0xLmFtYXpvbmF3cy5jb20v 22 | Y3JsL2Y4YWJlNTRlLTE5NTEtNDcwZS1hZTJkLWRlNDBjMTYzMzQxYy5jcmwwDQYJ 23 | KoZIhvcNAQELBQADggIBACJyObFxl/U7eNoTuA33urCk8hxGMugIpInK8dAFfzqe 24 | DXSJQyUebD66Sn+C1Co27d3pbHWXknP3DFYuJQVqQtSi97UJk4nX5IAonz4AWeyJ 25 | MriukRuhvvv6H3AdkRMGHQuXgu5OUxbDtOM63Ao0mqC51RQcqnnAgI5iMC+VukOB 26 | aW1kdNX5dSeKu7oDrmuB+dqxbi/9rcSyaTKASdLm1jK1vwYxOeJQe6TtjOWW5awx 27 | bsAjG3Wt+5pjT8xx1Hpy2QKHL/okHV6jzFGdzO5UNSSjkx6cBJQdZRAanJxI/C60 28 | MY0+FipHs76kzDlAmVQbf2YeePPqB4vQsW7g9QP9TpJiTuLn64LDrMPX5emvoZHA 29 | HJI8KaqdHLQn5nFmkhCp/Ym/BKGF9OC2Toib0PijncyqJ0dlGFnIx+1WtlWwZBzM 30 | OmHWsi7GHhrmHRr/VtAhmP4m19cxGK4ehx77nDETEpR/Eou8povVUsGa3XR2nE/g 31 | 8CwP66U2+ymxZIvRY2lhJb9cJ8orFRKG6iBx9c4TJEZvKckcNC2djksdFpI5j5Td 32 | eRokoepHimw1GzInB0v08utEAHXUJwloIesosq7KNjQ6+Ufe3E6YB0up+18EPf9x 33 | pEAe9uR8MfXEPAMRUl2bRhCUA0JRGPtVgOQlWHcdcvrT9BpSL0/c9EyHsA9vdgEZ 34 | -----END CERTIFICATE----- 35 | -----BEGIN CERTIFICATE----- 36 | MIIGaDCCBFCgAwIBAgITcAAAAAWaX7qDX+136AAAAAAABTANBgkqhkiG9w0BAQsF 37 | ADBNMQswCQYDVQQGEwJERTERMA8GA1UEBwwIV2FsbGRvcmYxDzANBgNVBAoMBlNB 38 | UCBTRTEaMBgGA1UEAwwRU0FQIENsb3VkIFJvb3QgQ0EwHhcNMjAwNjIzMDg0NzI4 39 | WhcNMzAwNjIzMDg1NzI4WjCBgDELMAkGA1UEBhMCREUxFDASBgNVBAcMC0VVMTAt 40 | Q2FuYXJ5MQ8wDQYDVQQKDAZTQVAgU0UxIzAhBgNVBAsMGlNBUCBDbG91ZCBQbGF0 41 | Zm9ybSBDbGllbnRzMSUwIwYDVQQDDBxTQVAgQ2xvdWQgUGxhdGZvcm0gQ2xpZW50 42 | IENBMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAuaFK8HPApmtIk85H 43 | nVAkA+OAYlhH/gJe02iZvP1bQ2K7Y45W7Si/QxxIpgLTvN63/e8+O+2uW/jBkoAr 44 | WUCgIYk7KonSuQtsPlFlhwRAnW3Qet687mNGA50eQtx243dySrsDUU2yUIDyous+ 45 | IHaBAWw1qyYAO1X6PYUOywYiQ0adLRZ/BHMXiR2Z/TCtDe6A4H+2EV0kxavtzZf4 46 | 5/SBXpFlAw7MVYPd+FT3mv3xHzPu/+PqY/lTIQgPokXVNV6kp0H29fu45N1s4WIo 47 | G9U6EBWdS2aA9W1BefRwkjB3t/OJ6lQkBrSzhkGcYL51UKKpHY1nXaLSH9lA2sjj 48 | sQxzdggarsk0wBP8fPnlFEHTSLb//b++dZVeQm5MBsITl2uGLvAjI2vtLsFQx+R2 49 | AdPyJasCuIAtVla/+A41Vt09h4jSv2d3KgrYG6KQt1FI+SFElu7swN0lF3rQku8v 50 | nmhB9s+J/3EgUlNmGnirfj4MflplaHJpkSenl/B9QHmIl58IKpvwtdEwZ7AQmb03 51 | KnyCtIHDQ+4Q5OHplGa0bQOGIz3il3eReheE+lXHS9Cyran/++/mRip7/VdWk9Sf 52 | Vv5Vnd+LXm/E74jvgr0km0jDw3qkcsOwAn6lcvfJPXF3t2Fb7BxHCfDiU7keSoy7 53 | XQpkWezzzN08vGoG5NHfKVdibxsCAwEAAaOCAQswggEHMBIGA1UdEwEB/wQIMAYB 54 | Af8CAQAwHQYDVR0OBBYEFMwghFuHFYs3+X6Hsck+w2+VBG6RMB8GA1UdIwQYMBaA 55 | FBy8ZisOyo1Ln42TcakPymdGaRMiMEoGA1UdHwRDMEEwP6A9oDuGOWh0dHA6Ly9j 56 | ZHAucGtpLmNvLnNhcC5jb20vY2RwL1NBUCUyMENsb3VkJTIwUm9vdCUyMENBLmNy 57 | bDBVBggrBgEFBQcBAQRJMEcwRQYIKwYBBQUHMAKGOWh0dHA6Ly9haWEucGtpLmNv 58 | LnNhcC5jb20vYWlhL1NBUCUyMENsb3VkJTIwUm9vdCUyMENBLmNydDAOBgNVHQ8B 59 | Af8EBAMCAQYwDQYJKoZIhvcNAQELBQADggIBADZygpVfAAC7dTaoKeEJ/8T8zeHX 60 | R93AEV2m52aX6yXCfzwkL92cW1zBsCuNi82K9PiNmzb/WVB5i7VdXUwAd7bI9ACb 61 | 0O/WkNHU+XB9Ta3VPQE14XL7jMaNHVLeaXA3iYcWqeuKQkYPHdMluBqcGmaYXnS0 62 | XSLocl+zRx0KMbQjvxCpGlf9XP52qqKyb1Gay152Kg2b+RmiKGqCBEHEoo2dXo/A 63 | D3N/Ei1CWkh/4hAw+scyyVC3S7L8ZyiLvaDYg013nt09S9wIIaB6Tub1+y2lK3PW 64 | HRVK9FEWraabKKVSOOXtrt+eVOCVJJwC7XjwFBywu2EgYuomoPf6qgcqWIr4cmBD 65 | qsHiAE3OygknSn2k97ooFGHTsyVt0AInhgVIk38Wip6F275JwX2xYMyyu0YiQEPT 66 | 5HdAoWcBIl4v6wZz1hWlF4FDD7zDns11ZCeLdCHss9NV8WJ6ClYNSQArtbIoYD1Y 67 | 9RzJr9LIlRPK82fM9b6peKQ2XUrTkMLFkIiI1HpT+Nt3JgtY/uDkXIV9nlXckDj6 68 | u9msfW8J9HU+cBQKAjfl1BoyLijQaXGoSvirJQSwh1Q9zLuH25uCkxhejZ8cDJrq 69 | p55i444meVi6Xf66WaHPWyJunQpN/zb14ZpMNB6PFp94gYSxPVyMhVWyCGK5C8mZ 70 | 2JX4S0blcGoU+np5 71 | -----END CERTIFICATE----- -------------------------------------------------------------------------------- /httpclient/testdata/otherTestingKey.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA TESTING KEY----- 2 | MIIEowIBAAKCAQEAsPnoGUOnrpiSqt4XynxA+HRP7S+BSObI6qJ7fQAVSPtRkqso 3 | tWxQYLEYzNEx5ZSHTGypibVsJylvCfuToDTfMul8b/CZjP2Ob0LdpYrNH6l5hvFE 4 | 89FU1nZQF15oVLOpUgA7wGiHuEVawrGfey92UE68mOyUVXGweJIVDdxqdMoPvNNU 5 | l86BU02vlBiESxOuox+dWmuVV7vfYZ79Toh/LUK43YvJh+rhv4nKuF7iHjVjBd9s 6 | B6iDjj70HFldzOQ9r8SRI+9NirupPTkF5AKNe6kUhKJ1luB7S27ZkvB3tSTT3P59 7 | 3VVJvnzOjaA1z6Cz+4+eRvcysqhrRgFlwI9TEwIDAQABAoIBAEEYiyDP29vCzx/+ 8 | dS3LqnI5BjUuJhXUnc6AWX/PCgVAO+8A+gZRgvct7PtZb0sM6P9ZcLrweomlGezI 9 | FrL0/6xQaa8bBr/ve/a8155OgcjFo6fZEw3Dz7ra5fbSiPmu4/b/kvrg+Br1l77J 10 | aun6uUAs1f5B9wW+vbR7tzbT/mxaUeDiBzKpe15GwcvbJtdIVMa2YErtRjc1/5B2 11 | BGVXyvlJv0SIlcIEMsHgnAFOp1ZgQ08aDzvilLq8XVMOahAhP1O2A3X8hKdXPyrx 12 | IVWE9bS9ptTo+eF6eNl+d7htpKGEZHUxinoQpWEBTv+iOoHsVunkEJ3vjLP3lyI/ 13 | fY0NQ1ECgYEA3RBXAjgvIys2gfU3keImF8e/TprLge1I2vbWmV2j6rZCg5r/AS0u 14 | pii5CvJ5/T5vfJPNgPBy8B/yRDs+6PJO1GmnlhOkG9JAIPkv0RBZvR0PMBtbp6nT 15 | Y3yo1lwamBVBfY6rc0sLTzosZh2aGoLzrHNMQFMGaauORzBFpY5lU50CgYEAzPHl 16 | u5DI6Xgep1vr8QvCUuEesCOgJg8Yh1UqVoY/SmQh6MYAv1I9bLGwrb3WW/7kqIoD 17 | fj0aQV5buVZI2loMomtU9KY5SFIsPV+JuUpy7/+VE01ZQM5FdY8wiYCQiVZYju9X 18 | Wz5LxMNoz+gT7pwlLCsC4N+R8aoBk404aF1gum8CgYAJ7VTq7Zj4TFV7Soa/T1eE 19 | k9y8a+kdoYk3BASpCHJ29M5R2KEA7YV9wrBklHTz8VzSTFTbKHEQ5W5csAhoL5Fo 20 | qoHzFFi3Qx7MHESQb9qHyolHEMNx6QdsHUn7rlEnaTTyrXh3ifQtD6C0yTmFXUIS 21 | CW9wKApOrnyKJ9nI0HcuZQKBgQCMtoV6e9VGX4AEfpuHvAAnMYQFgeBiYTkBKltQ 22 | XwozhH63uMMomUmtSG87Sz1TmrXadjAhy8gsG6I0pWaN7QgBuFnzQ/HOkwTm+qKw 23 | AsrZt4zeXNwsH7QXHEJCFnCmqw9QzEoZTrNtHJHpNboBuVnYcoueZEJrP8OnUG3r 24 | UjmopwKBgAqB2KYYMUqAOvYcBnEfLDmyZv9BTVNHbR2lKkMYqv5LlvDaBxVfilE0 25 | 2riO4p6BaAdvzXjKeRrGNEKoHNBpOSfYCOM16NjL8hIZB1CaV3WbT5oY+jp7Mzd5 26 | 7d56RZOE+ERK2uz/7JX9VSsM/LbH9pJibd4e8mikDS9ntciqOH/3 27 | -----END RSA TESTING KEY----- -------------------------------------------------------------------------------- /httpclient/testdata/privateTestingKey.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA TESTING KEY----- 2 | MIIEowIBAAKCAQEAoOJCt0fMMrdPqcaZhJt3izcNGosjNxy8QS+O2nCL93Li76Wl 3 | irbBU4DPcu83Bvw09B3JO3fNO0F9T4qmoEXGBcT8Sxk+jft8Xv2MA3cVv5yRZwgE 4 | G3SUsmMCei7sATlG8AnqGC3wqwGs/yaNS/M2bkldBl1y1ig7ft08z0HmzvyvT+/d 5 | WaIJDde0KmKS+fkm3OCiaCOBOJFYMAyQw3H6AM83QA8wDNcTkdsUlG2PbcXM8GSx 6 | QSm0WJpqM9tv7s52hVMHnZCSR5gIQeQ2PSQBwLyHhUxGphHFaxb/hUUw0ObLIfVI 7 | tJFrKdIxZmcohAr1O1BfZU118B0CW5FTqFf9CQIDAQABAoIBAG+/WRnXK/WaMCI7 8 | yQw7tFglX9utA0PXmMcqUm5VuFKjIQ/WHdrwv+3RTcaGc9FNQzbArbK5rvrWrUSf 9 | iMdQT5BYV+mN2k5ifOu57xPFFn0mMjS/c6LiYhpZ/TGC//iFoUk/ibNLzZvqKRB8 10 | 5a34fDk0igHOzOIFxfWDlCZdnwTrkAk3kO5jCdup44ee051Pt8mpQGVmvreSVpQK 11 | 8Y6A7XaTtO1+jDOBY91Fy52KcAJq3fGq+MaErobOJ4lA51Yg1HiGGHX54PEMPJJT 12 | 95tuHStX5LG3QFly4ORupwHDbTvKQ87xDuW1w+X8XSDkegfoAoqBs+7xcrdkAJBY 13 | loMThdUCgYEA6JH7jdH/D+4Q8D5MNlApqL+y6YV9AMUXsOZYzfolntiU+EMV0t12 14 | 1VXEduejWzTGs2Kxly93slc7qBYKcw69IgjPuzHEYnpwW1OK/zdweroKNofzAtZo 15 | 8h5GFPm6797hhpTUK6qTlFH4FRdKJh9PsvPDFZvySFu8VFgWVXAf+6cCgYEAsRd5 16 | 0u3+w1h6Dk+JRuSVL0tqxgwEozRJP688j8Du2BYm2MfPURUXu9DJ391siuFYUDsC 17 | EBQa+bOw0YpKewcVo90wJTqDuBxmDQyrO4NwdHQw734OBiR7TIqVp+3YzQ+DtZ2+ 18 | JDdduKI1/zF73bUZxQSn26zKdSjyvaJuF8gUl88CgYBRQgA0UvTdKf69Eecq6uND 19 | VIc8VCmSxUo7wp+wh//w+hdCjp3naP2GGEtmiBRpX401S/xkqG8X3qa3WcwY20N1 20 | ysJZ00+cYM80+YGNHl+sYagD2Ygsq6FLRwyRc5e/C46cqQ9gml6p6eHV7Kc5nqMI 21 | EWdN+4ixg2vPxF85Rs3F7QKBgQCdV3JRykr0XQP0+w3JAwbZgnRXig4Ew0vhXVy9 22 | jHmpW+Uf7kdwjwELSjJSyHTL3/OLNSJcDsD44oJTaj9Kl7zOXpOMQDUPu4ugRIVO 23 | 1zVvAl0ILENhicBS/T6CeXyKlSI8lu59VwPaK6U2G00mauV+euh48UjgV4V0n4CZ 24 | eJdzWQKBgEYIZkStNmKFaSp3LlHMbLT/MT7ILcj/xvHEg6kNuayTp5WIg6tCC8hW 25 | UeoziLhINxtmEJ0w8Goz31oKAbj8MLjkHhEPUOncHOLvxZcOTbt7hebaPXqaMBZl 26 | Ln3uMyGCyV8cj/LKWD5XHOsbwY3ZyvT6LHlbDN8eno5gIIsF7d8u 27 | -----END RSA TESTING KEY----- -------------------------------------------------------------------------------- /mocks/mockServer.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package mocks 6 | 7 | import ( 8 | "bytes" 9 | "crypto/rsa" 10 | "crypto/x509" 11 | _ "embed" 12 | "encoding/base64" 13 | "encoding/json" 14 | "encoding/pem" 15 | "fmt" 16 | "math/big" 17 | "net/http" 18 | "net/http/httptest" 19 | "net/url" 20 | "strings" 21 | "time" 22 | 23 | "github.com/google/uuid" 24 | "github.com/gorilla/mux" 25 | "github.com/lestrrat-go/jwx/jwa" 26 | "github.com/lestrrat-go/jwx/jwk" 27 | "github.com/lestrrat-go/jwx/jws" 28 | "github.com/lestrrat-go/jwx/jwt" 29 | 30 | "github.com/sap/cloud-security-client-go/httpclient" 31 | "github.com/sap/cloud-security-client-go/oidcclient" 32 | ) 33 | 34 | //go:embed testdata/privateTestingKey.pem 35 | var dummyKey string 36 | 37 | // MockServer serves as a single tenant OIDC mock server for tests. 38 | // Requests to the MockServer must be done by the mockServers client: MockServer.Server.Client() 39 | type MockServer struct { 40 | Server *httptest.Server // Server holds the httptest.Server and its Client. 41 | Config *MockConfig // Config holds the OIDC config which applications bind to the application. 42 | RSAKey *rsa.PrivateKey // RSAKey holds the servers private key to sign tokens. 43 | WellKnownHitCounter int // JWKsHitCounter holds the number of requests to the WellKnownHandler. 44 | JWKsHitCounter int // JWKsHitCounter holds the number of requests to the JWKsHandler. 45 | CustomIssuer string // CustomIssuer holds a custom domain returned by the discovery endpoint 46 | } 47 | 48 | // InvalidAppTID represents a guid which is rejected by mock server on behalf of IAS tenant 49 | const InvalidAppTID string = "dff69954-a259-4104-9074-193bc9a366ce" 50 | 51 | // NewOIDCMockServer instantiates a new MockServer. 52 | func NewOIDCMockServer() (*MockServer, error) { 53 | return newOIDCMockServer("") 54 | } 55 | 56 | // NewOIDCMockServerWithCustomIssuer instantiates a new MockServer with a custom issuer domain returned by the discovery endpoint. 57 | func NewOIDCMockServerWithCustomIssuer(customIssuer string) (*MockServer, error) { 58 | return newOIDCMockServer(customIssuer) 59 | } 60 | 61 | func newOIDCMockServer(customIssuer string) (*MockServer, error) { 62 | r := mux.NewRouter() 63 | block, _ := pem.Decode([]byte(strings.ReplaceAll(dummyKey, "TESTING KEY", "PRIVATE KEY"))) 64 | rsaKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) 65 | if err != nil { 66 | return nil, fmt.Errorf("unable to create mock server: error generating rsa key: %v", err) 67 | } 68 | server := httptest.NewTLSServer(r) 69 | 70 | domain, err := url.Parse(server.URL) 71 | if err != nil { 72 | return nil, fmt.Errorf("unable to create mock server: error parsing server url: %v", err) 73 | } 74 | mockServer := &MockServer{ 75 | Server: server, 76 | Config: &MockConfig{ 77 | ClientID: "clientid", 78 | ClientSecret: "clientsecret", 79 | URL: server.URL, 80 | Domains: []string{domain.Host}, 81 | }, 82 | RSAKey: rsaKey, 83 | CustomIssuer: customIssuer, 84 | } 85 | 86 | r.Use(verifyUserAgent) 87 | r.HandleFunc("/.well-known/openid-configuration", mockServer.WellKnownHandler).Methods(http.MethodGet) 88 | r.HandleFunc("/oauth2/certs", mockServer.JWKsHandlerInvalidAppTID).Methods(http.MethodGet).Headers("x-app_tid", InvalidAppTID) 89 | r.HandleFunc("/oauth2/certs", mockServer.JWKsHandler).Methods(http.MethodGet) 90 | r.HandleFunc("/oauth2/token", mockServer.tokenHandler).Methods(http.MethodPost).Headers("Content-Type", "application/x-www-form-urlencoded") 91 | 92 | return mockServer, nil 93 | } 94 | 95 | func verifyUserAgent(next http.Handler) http.Handler { 96 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 97 | if r.Header.Get("User-Agent") != httpclient.UserAgent { 98 | w.WriteHeader(http.StatusBadRequest) 99 | _, _ = w.Write([]byte("wrong user agent, expected: " + httpclient.UserAgent)) 100 | } 101 | next.ServeHTTP(w, r) 102 | }) 103 | } 104 | 105 | // ClearAllHitCounters resets all http handlers hit counters. See MockServer.WellKnownHitCounter and MockServer.JWKsHitCounter 106 | func (m *MockServer) ClearAllHitCounters() { 107 | m.WellKnownHitCounter = 0 108 | m.JWKsHitCounter = 0 109 | } 110 | 111 | // WellKnownHandler is the http handler which answers requests to the mock servers OIDC discovery endpoint. 112 | func (m *MockServer) WellKnownHandler(w http.ResponseWriter, _ *http.Request) { 113 | m.WellKnownHitCounter++ 114 | issuer := m.Config.URL 115 | if m.CustomIssuer != "" { 116 | issuer = m.CustomIssuer 117 | } 118 | wellKnown := oidcclient.ProviderJSON{ 119 | Issuer: issuer, 120 | JWKsURL: fmt.Sprintf("%s/oauth2/certs", m.Server.URL), 121 | } 122 | payload, _ := json.Marshal(wellKnown) 123 | _, _ = w.Write(payload) 124 | } 125 | 126 | // tokenHandler is the http handler which serves the /oauth2/token endpoint. It returns a token without claims. 127 | func (m *MockServer) tokenHandler(w http.ResponseWriter, r *http.Request) { 128 | grantType := r.PostFormValue("grant_type") 129 | clientID := r.PostFormValue("client_id") 130 | if grantType == "client_credentials" && clientID == m.Config.ClientID { 131 | _ = json.NewEncoder(w).Encode(tokenResponse{ 132 | Token: "eyJhbGciOiJIUzI1NiJ9.e30.ZRrHA1JJJW8opsbCGfG_HACGpVUMN_a9IV7pAx_Zmeo", 133 | }) 134 | } else { 135 | w.WriteHeader(http.StatusUnauthorized) 136 | } 137 | } 138 | 139 | // JWKsHandler is the http handler which answers requests to the JWKS endpoint. 140 | func (m *MockServer) JWKsHandler(w http.ResponseWriter, _ *http.Request) { 141 | m.JWKsHitCounter++ 142 | key := &JSONWebKey{ 143 | Kid: "testKey", 144 | Kty: "RSA", 145 | Alg: "RS256", 146 | E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(m.RSAKey.E)).Bytes()), 147 | N: base64.RawURLEncoding.EncodeToString(m.RSAKey.N.Bytes()), 148 | Use: "sig", 149 | } 150 | keySet := JSONWebKeySet{Keys: []*JSONWebKey{key}} 151 | payload, _ := json.Marshal(keySet) 152 | _, _ = w.Write(payload) 153 | } 154 | 155 | // JWKsHandlerInvalidAppTID is the http handler which answers invalid requests to the JWKS endpoint. 156 | // in reality, it returns "{ \"msg\":\"Invalid app_tid provided\" }" 157 | func (m *MockServer) JWKsHandlerInvalidAppTID(w http.ResponseWriter, _ *http.Request) { 158 | m.JWKsHitCounter++ 159 | w.WriteHeader(http.StatusBadRequest) 160 | } 161 | 162 | // SignToken signs the provided OIDCClaims and header fields into a base64 encoded JWT token signed by the MockServer. 163 | func (m *MockServer) SignToken(claims OIDCClaims, header map[string]interface{}) (string, error) { 164 | var mapClaims map[string]interface{} 165 | 166 | dataBytes, err := json.Marshal(claims) 167 | if err != nil { 168 | return "", fmt.Errorf("unable to convert OIDCClaims to map (marshal): %v", err) 169 | } 170 | err = json.Unmarshal(dataBytes, &mapClaims) 171 | if err != nil { 172 | return "", fmt.Errorf("unable to convert OIDCClaims to map (unmarshal): %v", err) 173 | } 174 | 175 | jwtToken := jwt.New() 176 | 177 | for k, v := range mapClaims { 178 | err := jwtToken.Set(k, v) 179 | if err != nil { 180 | return "", fmt.Errorf("unable to convert OIDCClaims to map: %v", err) 181 | } 182 | } 183 | 184 | return m.signToken(jwtToken, header) 185 | } 186 | 187 | // SignTokenWithAdditionalClaims signs the token with additional non-standard oidc claims. additionalClaims must not contain any oidc standard claims or duplicates. 188 | // See also: SignToken 189 | func (m *MockServer) SignTokenWithAdditionalClaims(claims OIDCClaims, additionalClaims, header map[string]interface{}) (string, error) { 190 | var mapClaims map[string]interface{} 191 | 192 | dataBytes, err := json.Marshal(claims) 193 | if err != nil { 194 | return "", fmt.Errorf("unable to convert OIDCClaims to map (marshal): %v", err) 195 | } 196 | err = json.Unmarshal(dataBytes, &mapClaims) 197 | if err != nil { 198 | return "", fmt.Errorf("unable to convert OIDCClaims to map (unmarshal): %v", err) 199 | } 200 | 201 | for k, v := range additionalClaims { 202 | if _, exists := mapClaims[k]; exists { 203 | return "", fmt.Errorf("additional claims must not contain any OIDC standard claims or duplicates. use claims parameter instead") 204 | } 205 | mapClaims[k] = v 206 | } 207 | token := jwt.New() 208 | 209 | for k, v := range mapClaims { 210 | err := token.Set(k, v) 211 | if err != nil { 212 | return "", fmt.Errorf("unable to convert OIDCClaims to map: %v", err) 213 | } 214 | } 215 | 216 | return m.signToken(token, header) 217 | } 218 | 219 | func (m *MockServer) signToken(token jwt.Token, header map[string]interface{}) (string, error) { 220 | jwkKey, err := jwk.New(m.RSAKey) 221 | if err != nil { 222 | return "", fmt.Errorf("failed to create JWK: %s", err) 223 | } 224 | 225 | _ = jwkKey.Set(jwk.KeyIDKey, header[headerKid]) 226 | 227 | signedJwt, err := jwt.Sign(token, jwa.RS256, jwkKey) 228 | if err != nil { 229 | return "", fmt.Errorf("failed to sign the token: %v", err) 230 | } 231 | 232 | var alg, ok = header[headerAlg].(jwa.SignatureAlgorithm) 233 | if !ok || alg != jwa.RS256 { 234 | signedJwt, _ = modifySignedJwtHeader(signedJwt, header) 235 | } 236 | 237 | return string(signedJwt), nil 238 | } 239 | func modifySignedJwtHeader(signed []byte, headerMap map[string]interface{}) ([]byte, error) { 240 | _, payload, signature, err := jws.SplitCompact(signed) 241 | if err != nil { 242 | return nil, fmt.Errorf("failed to modify Jwt signature: %s", err) 243 | } 244 | 245 | headers := jws.NewHeaders() 246 | _ = headers.Set(jws.AlgorithmKey, headerMap[headerAlg]) 247 | _ = headers.Set(jws.KeyIDKey, headerMap[headerKid]) 248 | 249 | marshaledHeaders, err := json.Marshal(headers) 250 | if err != nil { 251 | return nil, fmt.Errorf("failed to modify Jwt signature: %s", err) 252 | } 253 | encodedHeaders := make([]byte, base64.RawURLEncoding.EncodedLen(len(marshaledHeaders))) 254 | base64.RawURLEncoding.Encode(encodedHeaders, marshaledHeaders) 255 | 256 | signedWithModifiedHeaders := bytes.Join([][]byte{encodedHeaders, payload, signature}, []byte{'.'}) 257 | 258 | return signedWithModifiedHeaders, nil 259 | } 260 | 261 | // DefaultClaims returns OIDCClaims with mock server specific default values for standard OIDC claims. 262 | func (m *MockServer) DefaultClaims() OIDCClaims { 263 | now := time.Now().Unix() 264 | after5min := now + 5*60*1000 265 | claims := OIDCClaims{ 266 | 267 | Audience: []string{m.Config.ClientID}, 268 | ExpiresAt: after5min, 269 | ID: uuid.New().String(), 270 | IssuedAt: now, 271 | Issuer: m.Server.URL, 272 | NotBefore: now, 273 | GivenName: "Foo", 274 | FamilyName: "Bar", 275 | Email: "foo@bar.org", 276 | AppTID: "11111111-2222-3333-4444-888888888888", 277 | UserUUID: "22222222-3333-4444-5555-666666666666", 278 | } 279 | return claims 280 | } 281 | 282 | // DefaultHeaders returns JWT headers with mock server specific default values. 283 | func (m *MockServer) DefaultHeaders() map[string]interface{} { 284 | header := make(map[string]interface{}) 285 | 286 | header["typ"] = "JWT" 287 | header[headerAlg] = jwa.RS256 288 | header[headerKid] = "testKey" 289 | 290 | return header 291 | } 292 | 293 | // MockConfig represents the credentials to the mock server 294 | type MockConfig struct { 295 | ClientID string 296 | ClientSecret string 297 | URL string 298 | Domains []string 299 | ZoneUUID uuid.UUID 300 | AppTID string 301 | ProofTokenURL string 302 | OsbURL string 303 | Certificate string 304 | Key string 305 | CertificateExpiresAt string 306 | AuthorizationInstanceID string 307 | AuthorizationBundleURL string 308 | } 309 | 310 | // GetClientID implements the env.Identity interface. 311 | func (c MockConfig) GetClientID() string { 312 | return c.ClientID 313 | } 314 | 315 | // GetClientSecret implements the env.Identity interface. 316 | func (c MockConfig) GetClientSecret() string { 317 | return c.ClientSecret 318 | } 319 | 320 | // GetURL implements the env.Identity interface. 321 | func (c MockConfig) GetURL() string { 322 | return c.URL 323 | } 324 | 325 | // GetDomains implements the env.Identity interface. 326 | func (c MockConfig) GetDomains() []string { 327 | return c.Domains 328 | } 329 | 330 | // GetZoneUUID implements the env.Identity interface. 331 | func (c MockConfig) GetZoneUUID() uuid.UUID { 332 | return c.ZoneUUID 333 | } 334 | 335 | // GetAppTID implements the env.Identity interface. 336 | func (c MockConfig) GetAppTID() string { 337 | return c.AppTID 338 | } 339 | 340 | // GetProofTokenURL implements the env.Identity interface. 341 | func (c MockConfig) GetProofTokenURL() string { 342 | return c.ProofTokenURL 343 | } 344 | 345 | // GetOsbURL implements the env.Identity interface. 346 | func (c MockConfig) GetOsbURL() string { 347 | return c.OsbURL 348 | } 349 | 350 | // GetCertificate implements the env.Identity interface. 351 | func (c MockConfig) GetCertificate() string { 352 | return c.Certificate 353 | } 354 | 355 | // GetKey implements the env.Identity interface. 356 | func (c MockConfig) GetKey() string { 357 | return c.Key 358 | } 359 | 360 | // GetCertificateExpiresAt implements the env.Identity interface. 361 | func (c MockConfig) GetCertificateExpiresAt() string { 362 | return c.CertificateExpiresAt 363 | } 364 | 365 | // IsCertificateBased implements the env.Identity interface. 366 | func (c MockConfig) IsCertificateBased() bool { 367 | return c.Certificate != "" && c.Key != "" 368 | } 369 | 370 | // GetAuthorizationInstanceID implements the env.Identity interface. 371 | func (c MockConfig) GetAuthorizationInstanceID() string { return c.AuthorizationInstanceID } 372 | 373 | // GetAuthorizationInstanceID implements the env.Identity interface. 374 | func (c MockConfig) GetAuthorizationBundleURL() string { return c.AuthorizationBundleURL } 375 | 376 | // JSONWebKeySet represents the data which is returned by the tenants /oauth2/certs endpoint 377 | type JSONWebKeySet struct { 378 | Keys []*JSONWebKey `json:"keys"` 379 | } 380 | 381 | // JSONWebKey represents a single JWK 382 | type JSONWebKey struct { 383 | Kty string `json:"kty"` 384 | E string `json:"e"` 385 | N string `json:"n"` 386 | Use string `json:"use"` 387 | Kid string `json:"kid"` 388 | Alg string `json:"alg"` 389 | Key interface{} 390 | } 391 | 392 | type tokenResponse struct { 393 | Token string `json:"access_token"` 394 | } 395 | -------------------------------------------------------------------------------- /mocks/mockServer_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package mocks 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/sap/cloud-security-client-go/auth" 11 | ) 12 | 13 | func TestMockServer_SignTokenWithAdditionalClaims(t *testing.T) { 14 | oidcMockServer, _ := NewOIDCMockServer() 15 | defer oidcMockServer.Server.Close() 16 | 17 | tests := []struct { 18 | name string 19 | claims OIDCClaims 20 | additionalClaims map[string]interface{} 21 | wantErr bool 22 | }{ 23 | { 24 | name: "additional claim", 25 | claims: oidcMockServer.DefaultClaims(), 26 | additionalClaims: map[string]interface{}{ 27 | "ias-admin": "true", 28 | }, 29 | wantErr: false, 30 | }, { 31 | name: "standard oidc claim in additional claim", 32 | claims: oidcMockServer.DefaultClaims(), 33 | additionalClaims: map[string]interface{}{ 34 | "user_uuid": "fake", 35 | }, 36 | wantErr: true, 37 | }, 38 | } 39 | for _, tt := range tests { 40 | t.Run(tt.name, func(t *testing.T) { 41 | signedToken, err := oidcMockServer.SignTokenWithAdditionalClaims(tt.claims, tt.additionalClaims, oidcMockServer.DefaultHeaders()) 42 | if (err != nil) != tt.wantErr { 43 | t.Errorf("SignTokenWithAdditionalClaims() error = %v, wantErr %v", err, tt.wantErr) 44 | return 45 | } 46 | if !tt.wantErr { 47 | token, err := auth.NewToken(signedToken) 48 | if err != nil { 49 | t.Errorf("SignTokenWithAdditionalClaims() error = %v, wantErr %v", err, tt.wantErr) 50 | } 51 | for k, v := range tt.additionalClaims { 52 | if value, err := token.GetClaimAsString(k); err != nil || v != value { 53 | t.Errorf("additional claim %s missing in token or has wrong value %v vs %v ", k, v, value) 54 | } 55 | } 56 | } 57 | }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /mocks/oidcClaims.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package mocks 6 | 7 | // https://www.iana.org/assignments/jwt/jwt.xhtml#claims 8 | const ( 9 | headerKid = "kid" 10 | headerAlg = "alg" 11 | ) 12 | 13 | // OIDCClaims represents all claims that the JWT holds 14 | type OIDCClaims struct { 15 | Audience []string `json:"aud,omitempty"` 16 | ExpiresAt int64 `json:"exp,omitempty"` 17 | ID string `json:"jti,omitempty"` 18 | IssuedAt int64 `json:"iat,omitempty"` 19 | Issuer string `json:"iss,omitempty"` 20 | IasIssuer string `json:"ias_iss,omitempty"` 21 | NotBefore int64 `json:"nbf,omitempty"` 22 | Subject string `json:"sub,omitempty"` 23 | GivenName string `json:"given_name,omitempty"` 24 | FamilyName string `json:"family_name,omitempty"` 25 | Email string `json:"email,omitempty"` 26 | ZoneID string `json:"zone_uuid,omitempty"` 27 | AppTID string `json:"app_tid,omitempty"` 28 | UserUUID string `json:"user_uuid,omitempty"` 29 | } 30 | -------------------------------------------------------------------------------- /mocks/oidcTokenBuilder.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package mocks 6 | 7 | import ( 8 | "time" 9 | 10 | "github.com/lestrrat-go/jwx/jwa" 11 | ) 12 | 13 | // OIDCHeaderBuilder can construct header fields for test cases 14 | type OIDCHeaderBuilder struct { 15 | header map[string]interface{} 16 | } 17 | 18 | // NewOIDCHeaderBuilder instantiates a new OIDCHeaderBuilder with a base (e.g. MockServer.DefaultHeaders) 19 | func NewOIDCHeaderBuilder(base map[string]interface{}) *OIDCHeaderBuilder { 20 | b := &OIDCHeaderBuilder{base} 21 | return b 22 | } 23 | 24 | // KeyID sets the keyID field 25 | func (b *OIDCHeaderBuilder) KeyID(keyID string) *OIDCHeaderBuilder { 26 | if keyID == "" { 27 | b.header[headerKid] = nil 28 | } else { 29 | b.header[headerKid] = keyID 30 | } 31 | return b 32 | } 33 | 34 | // Alg sets the alg field 35 | func (b *OIDCHeaderBuilder) Alg(alg jwa.SignatureAlgorithm) *OIDCHeaderBuilder { 36 | if alg == "" { 37 | b.header[headerAlg] = nil 38 | } else { 39 | b.header[headerAlg] = alg 40 | } 41 | return b 42 | } 43 | 44 | // Build returns the finished http header fields 45 | func (b *OIDCHeaderBuilder) Build() map[string]interface{} { 46 | return b.header 47 | } 48 | 49 | // OIDCClaimsBuilder can construct token claims for test cases. Use NewOIDCClaimsBuilder as a constructor. 50 | type OIDCClaimsBuilder struct { 51 | claims OIDCClaims 52 | } 53 | 54 | // NewOIDCClaimsBuilder instantiates a new OIDCClaimsBuilder with a base (e.g. MockServer.DefaultClaims) 55 | func NewOIDCClaimsBuilder(base OIDCClaims) *OIDCClaimsBuilder { 56 | b := &OIDCClaimsBuilder{base} 57 | return b 58 | } 59 | 60 | // Build returns the finished token OIDCClaims 61 | func (b *OIDCClaimsBuilder) Build() OIDCClaims { 62 | return b.claims 63 | } 64 | 65 | // Audience sets the aud field 66 | func (b *OIDCClaimsBuilder) Audience(aud ...string) *OIDCClaimsBuilder { 67 | b.claims.Audience = aud 68 | return b 69 | } 70 | 71 | // ExpiresAt sets the exp field 72 | func (b *OIDCClaimsBuilder) ExpiresAt(expiresAt time.Time) *OIDCClaimsBuilder { 73 | b.claims.ExpiresAt = expiresAt.Unix() 74 | return b 75 | } 76 | 77 | // ID sets the id field 78 | func (b *OIDCClaimsBuilder) ID(id string) *OIDCClaimsBuilder { 79 | b.claims.ID = id 80 | return b 81 | } 82 | 83 | // IssuedAt sets the iat field 84 | func (b *OIDCClaimsBuilder) IssuedAt(issuedAt time.Time) *OIDCClaimsBuilder { 85 | b.claims.IssuedAt = issuedAt.Unix() 86 | return b 87 | } 88 | 89 | // Issuer sets the iss field 90 | func (b *OIDCClaimsBuilder) Issuer(issuer string) *OIDCClaimsBuilder { 91 | b.claims.Issuer = issuer 92 | return b 93 | } 94 | 95 | // IasIssuer sets the ias_iss field 96 | func (b *OIDCClaimsBuilder) IasIssuer(issuer string) *OIDCClaimsBuilder { 97 | b.claims.IasIssuer = issuer 98 | return b 99 | } 100 | 101 | // NotBefore sets the nbf field 102 | func (b *OIDCClaimsBuilder) NotBefore(notBefore time.Time) *OIDCClaimsBuilder { 103 | b.claims.NotBefore = notBefore.Unix() 104 | return b 105 | } 106 | 107 | // Subject sets the sub field 108 | func (b *OIDCClaimsBuilder) Subject(subject string) *OIDCClaimsBuilder { 109 | b.claims.Subject = subject 110 | return b 111 | } 112 | 113 | // UserUUID sets the user_uuid field 114 | func (b *OIDCClaimsBuilder) UserUUID(userUUID string) *OIDCClaimsBuilder { 115 | b.claims.UserUUID = userUUID 116 | return b 117 | } 118 | 119 | // GivenName sets the given_name field 120 | func (b *OIDCClaimsBuilder) GivenName(givenName string) *OIDCClaimsBuilder { 121 | b.claims.GivenName = givenName 122 | return b 123 | } 124 | 125 | // FamilyName sets the family_name field 126 | func (b *OIDCClaimsBuilder) FamilyName(familyName string) *OIDCClaimsBuilder { 127 | b.claims.FamilyName = familyName 128 | return b 129 | } 130 | 131 | // Email sets the email field 132 | func (b *OIDCClaimsBuilder) Email(email string) *OIDCClaimsBuilder { 133 | b.claims.Email = email 134 | return b 135 | } 136 | 137 | // ZoneID sets the zone_uuid field 138 | func (b *OIDCClaimsBuilder) ZoneID(zoneID string) *OIDCClaimsBuilder { 139 | b.claims.ZoneID = zoneID 140 | return b 141 | } 142 | 143 | // AppTID sets the app_tid field 144 | func (b *OIDCClaimsBuilder) AppTID(appTID string) *OIDCClaimsBuilder { 145 | b.claims.AppTID = appTID 146 | return b 147 | } 148 | 149 | // WithoutAudience removes the aud claim 150 | func (b *OIDCClaimsBuilder) WithoutAudience() *OIDCClaimsBuilder { 151 | b.claims.Audience = nil 152 | return b 153 | } 154 | 155 | // WithoutExpiresAt removes the exp claim 156 | func (b *OIDCClaimsBuilder) WithoutExpiresAt() *OIDCClaimsBuilder { 157 | b.claims.ExpiresAt = 0 158 | return b 159 | } 160 | 161 | // WithoutIssuedAt removes the iat claim 162 | func (b *OIDCClaimsBuilder) WithoutIssuedAt() *OIDCClaimsBuilder { 163 | b.claims.IssuedAt = 0 164 | return b 165 | } 166 | 167 | // WithoutNotBefore removes the nbf claim 168 | func (b *OIDCClaimsBuilder) WithoutNotBefore() *OIDCClaimsBuilder { 169 | b.claims.NotBefore = 0 170 | return b 171 | } 172 | -------------------------------------------------------------------------------- /mocks/testdata/privateTestingKey.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA TESTING KEY----- 2 | MIIEowIBAAKCAQEAsPnoGUOnrpiSqt4XynxA+HRP7S+BSObI6qJ7fQAVSPtRkqso 3 | tWxQYLEYzNEx5ZSHTGypibVsJylvCfuToDTfMul8b/CZjP2Ob0LdpYrNH6l5hvFE 4 | 89FU1nZQF15oVLOpUgA7wGiHuEVawrGfey92UE68mOyUVXGweJIVDdxqdMoPvNNU 5 | l86BU02vlBiESxOuox+dWmuVV7vfYZ79Toh/LUK43YvJh+rhv4nKuF7iHjVjBd9s 6 | B6iDjj70HFldzOQ9r8SRI+9NirupPTkF5AKNe6kUhKJ1luB7S27ZkvB3tSTT3P59 7 | 3VVJvnzOjaA1z6Cz+4+eRvcysqhrRgFlwI9TEwIDAQABAoIBAEEYiyDP29vCzx/+ 8 | dS3LqnI5BjUuJhXUnc6AWX/PCgVAO+8A+gZRgvct7PtZb0sM6P9ZcLrweomlGezI 9 | FrL0/6xQaa8bBr/ve/a8155OgcjFo6fZEw3Dz7ra5fbSiPmu4/b/kvrg+Br1l77J 10 | aun6uUAs1f5B9wW+vbR7tzbT/mxaUeDiBzKpe15GwcvbJtdIVMa2YErtRjc1/5B2 11 | BGVXyvlJv0SIlcIEMsHgnAFOp1ZgQ08aDzvilLq8XVMOahAhP1O2A3X8hKdXPyrx 12 | IVWE9bS9ptTo+eF6eNl+d7htpKGEZHUxinoQpWEBTv+iOoHsVunkEJ3vjLP3lyI/ 13 | fY0NQ1ECgYEA3RBXAjgvIys2gfU3keImF8e/TprLge1I2vbWmV2j6rZCg5r/AS0u 14 | pii5CvJ5/T5vfJPNgPBy8B/yRDs+6PJO1GmnlhOkG9JAIPkv0RBZvR0PMBtbp6nT 15 | Y3yo1lwamBVBfY6rc0sLTzosZh2aGoLzrHNMQFMGaauORzBFpY5lU50CgYEAzPHl 16 | u5DI6Xgep1vr8QvCUuEesCOgJg8Yh1UqVoY/SmQh6MYAv1I9bLGwrb3WW/7kqIoD 17 | fj0aQV5buVZI2loMomtU9KY5SFIsPV+JuUpy7/+VE01ZQM5FdY8wiYCQiVZYju9X 18 | Wz5LxMNoz+gT7pwlLCsC4N+R8aoBk404aF1gum8CgYAJ7VTq7Zj4TFV7Soa/T1eE 19 | k9y8a+kdoYk3BASpCHJ29M5R2KEA7YV9wrBklHTz8VzSTFTbKHEQ5W5csAhoL5Fo 20 | qoHzFFi3Qx7MHESQb9qHyolHEMNx6QdsHUn7rlEnaTTyrXh3ifQtD6C0yTmFXUIS 21 | CW9wKApOrnyKJ9nI0HcuZQKBgQCMtoV6e9VGX4AEfpuHvAAnMYQFgeBiYTkBKltQ 22 | XwozhH63uMMomUmtSG87Sz1TmrXadjAhy8gsG6I0pWaN7QgBuFnzQ/HOkwTm+qKw 23 | AsrZt4zeXNwsH7QXHEJCFnCmqw9QzEoZTrNtHJHpNboBuVnYcoueZEJrP8OnUG3r 24 | UjmopwKBgAqB2KYYMUqAOvYcBnEfLDmyZv9BTVNHbR2lKkMYqv5LlvDaBxVfilE0 25 | 2riO4p6BaAdvzXjKeRrGNEKoHNBpOSfYCOM16NjL8hIZB1CaV3WbT5oY+jp7Mzd5 26 | 7d56RZOE+ERK2uz/7JX9VSsM/LbH9pJibd4e8mikDS9ntciqOH/3 27 | -----END RSA TESTING KEY----- -------------------------------------------------------------------------------- /oidcclient/jwk.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package oidcclient 6 | 7 | import ( 8 | "context" 9 | "encoding/json" 10 | "fmt" 11 | "io" 12 | "mime" 13 | "net/http" 14 | "strings" 15 | "sync" 16 | "time" 17 | 18 | "github.com/lestrrat-go/jwx/jwk" 19 | "github.com/pquerna/cachecontrol" 20 | "github.com/sap/cloud-security-client-go/httpclient" 21 | ) 22 | 23 | const defaultJwkExpiration = 15 * time.Minute 24 | const appTIDHeader = "x-app_tid" 25 | const clientIDHeader = "x-client_id" 26 | const azpHeader = "x-azp" 27 | 28 | // OIDCTenant represents one IAS tenant correlating with one app_tid and client_id with it's OIDC discovery results and cached JWKs 29 | type OIDCTenant struct { 30 | ProviderJSON ProviderJSON 31 | acceptedClients map[ClientInfo]bool 32 | httpClient *http.Client 33 | // A set of cached keys and their expiry. 34 | jwks jwk.Set 35 | jwksExpiry time.Time 36 | mu sync.RWMutex 37 | } 38 | 39 | type ClientInfo struct { 40 | ClientID string 41 | AppTID string 42 | Azp string 43 | } 44 | 45 | type updateKeysResult struct { 46 | keys jwk.Set 47 | expiry time.Time 48 | } 49 | 50 | // NewOIDCTenant instantiates a new OIDCTenant and performs the OIDC discovery 51 | func NewOIDCTenant(httpClient *http.Client, targetIssHost string) (*OIDCTenant, error) { 52 | ks := new(OIDCTenant) 53 | ks.httpClient = httpClient 54 | ks.acceptedClients = make(map[ClientInfo]bool) 55 | err := ks.performDiscovery(targetIssHost) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | return ks, nil 61 | } 62 | 63 | // GetJWKs returns the validation keys either cached or updated ones 64 | func (ks *OIDCTenant) GetJWKs(clientInfo ClientInfo) (jwk.Set, error) { 65 | keys, err := ks.readJWKsFromMemory(clientInfo) 66 | if keys == nil { 67 | if err != nil { 68 | return nil, err 69 | } 70 | return ks.updateJWKsMemory(clientInfo) 71 | } 72 | return keys, nil 73 | } 74 | 75 | // readJWKsFromMemory returns the validation keys from memory, or error in case of invalid header combination or nil, in case nothing found in memory 76 | func (ks *OIDCTenant) readJWKsFromMemory(clientInfo ClientInfo) (jwk.Set, error) { 77 | ks.mu.RLock() 78 | defer ks.mu.RUnlock() 79 | 80 | isClientAccepted, isClientKnown := ks.acceptedClients[clientInfo] 81 | 82 | if time.Now().Before(ks.jwksExpiry) && isClientKnown { 83 | if isClientAccepted { 84 | return ks.jwks, nil 85 | } 86 | return nil, fmt.Errorf("client credentials: %+v are not accepted by the identity service", clientInfo) 87 | } 88 | return nil, nil 89 | } 90 | 91 | // updateJWKsMemory updates and returns the validation keys from memory, or error in case of invalid header combination nil, in case nothing found in memory 92 | func (ks *OIDCTenant) updateJWKsMemory(clientInfo ClientInfo) (jwk.Set, error) { 93 | ks.mu.Lock() 94 | defer ks.mu.Unlock() 95 | 96 | updatedKeys, err := ks.getJWKsFromServer(clientInfo) 97 | if err != nil { 98 | return nil, fmt.Errorf("error updating JWKs: %v", err) 99 | } 100 | keysResult := updatedKeys.(updateKeysResult) 101 | 102 | ks.jwksExpiry = keysResult.expiry 103 | ks.jwks = keysResult.keys 104 | return ks.jwks, nil 105 | } 106 | 107 | func (ks *OIDCTenant) getJWKsFromServer(clientInfo ClientInfo) (r interface{}, err error) { 108 | result := updateKeysResult{} 109 | req, err := httpclient.NewRequestWithUserAgent(context.TODO(), http.MethodGet, ks.ProviderJSON.JWKsURL, http.NoBody) 110 | if err != nil { 111 | return result, fmt.Errorf("can't create request to fetch jwk: %v", err) 112 | } 113 | // at least client-id is necessary, all further headers only refine the validation 114 | req.Header.Add(clientIDHeader, clientInfo.ClientID) 115 | req.Header.Add(appTIDHeader, clientInfo.AppTID) 116 | req.Header.Add(azpHeader, clientInfo.Azp) 117 | 118 | resp, err := ks.httpClient.Do(req) 119 | if err != nil { 120 | return result, fmt.Errorf("failed to fetch jwks from remote: %v", err) 121 | } 122 | defer resp.Body.Close() 123 | 124 | if resp.StatusCode != http.StatusOK { 125 | // prevent caching ias backend flaps like 503 -> only cache 400 126 | if resp.StatusCode == http.StatusBadRequest { 127 | ks.acceptedClients[clientInfo] = false 128 | } 129 | resp, err := io.ReadAll(resp.Body) 130 | if err != nil { 131 | return result, fmt.Errorf( 132 | "failed to fetch jwks from remote for client credentials %+v: %v", clientInfo, err) 133 | } 134 | return result, fmt.Errorf( 135 | "failed to fetch jwks from remote for client credentials %+v: (%s)", clientInfo, resp) 136 | } 137 | ks.acceptedClients[clientInfo] = true 138 | jwks, err := jwk.ParseReader(resp.Body) 139 | if err != nil { 140 | return nil, fmt.Errorf("failed to parse JWK set: %w", err) 141 | } 142 | result.keys = jwks 143 | // If the server doesn't provide cache control headers, assume the keys expire in 15min. 144 | result.expiry = time.Now().Add(defaultJwkExpiration) 145 | 146 | _, e, err := cachecontrol.CachableResponse(req, resp, cachecontrol.Options{}) 147 | if err == nil && e.After(result.expiry) { 148 | result.expiry = e 149 | } 150 | return result, nil 151 | } 152 | 153 | func (ks *OIDCTenant) performDiscovery(baseURL string) error { 154 | wellKnown := fmt.Sprintf("https://%s/.well-known/openid-configuration", strings.TrimSuffix(baseURL, "/")) 155 | req, err := httpclient.NewRequestWithUserAgent(context.TODO(), http.MethodGet, wellKnown, http.NoBody) 156 | if err != nil { 157 | return fmt.Errorf("unable to construct discovery request: %v", err) 158 | } 159 | resp, err := ks.httpClient.Do(req) 160 | if err != nil { 161 | return fmt.Errorf("unable to perform oidc discovery request: %v", err) 162 | } 163 | defer resp.Body.Close() 164 | body, err := io.ReadAll(resp.Body) 165 | if err != nil { 166 | return fmt.Errorf("unable to read response body: %v", err) 167 | } 168 | 169 | if resp.StatusCode != http.StatusOK { 170 | return fmt.Errorf("%s: %s", resp.Status, body) 171 | } 172 | 173 | var p ProviderJSON 174 | err = unmarshalResponse(resp, body, &p) 175 | if err != nil { 176 | return fmt.Errorf("failed to decode provider discovery object: %v", err) 177 | } 178 | err = p.assertMandatoryFieldsPresent() 179 | if err != nil { 180 | return fmt.Errorf("oidc discovery for %v failed: %v", wellKnown, err) 181 | } 182 | ks.ProviderJSON = p 183 | 184 | return nil 185 | } 186 | 187 | // ProviderJSON represents data which is returned by the tenants /.well-known/openid-configuration endpoint 188 | type ProviderJSON struct { 189 | Issuer string `json:"issuer"` 190 | AuthURL string `json:"authorization_endpoint"` 191 | TokenURL string `json:"token_endpoint"` 192 | JWKsURL string `json:"jwks_uri"` 193 | UserInfoURL string `json:"userinfo_endpoint"` 194 | } 195 | 196 | func (p ProviderJSON) assertMandatoryFieldsPresent() error { 197 | var missing []string 198 | if p.Issuer == "" { 199 | missing = append(missing, "issuer") 200 | } 201 | if p.JWKsURL == "" { 202 | missing = append(missing, "jwks_uri") 203 | } 204 | if len(missing) > 0 { 205 | str := "'" + strings.Join(missing, "','") + "'" 206 | return fmt.Errorf("missing following mandatory fields in the OIDC discovery response: %v", str) 207 | } 208 | return nil 209 | } 210 | 211 | func unmarshalResponse(r *http.Response, body []byte, v interface{}) error { 212 | err := json.Unmarshal(body, &v) 213 | if err == nil { 214 | return nil 215 | } 216 | ct := r.Header.Get("Content-Type") 217 | mediaType, _, parseErr := mime.ParseMediaType(ct) 218 | if parseErr == nil && mediaType == "application/json" { 219 | return fmt.Errorf("got Content-Type = application/json, but could not unmarshal as JSON: %v", err) 220 | } 221 | 222 | return fmt.Errorf("expected Content-Type = application/json, got %q: %v", ct, err) 223 | } 224 | -------------------------------------------------------------------------------- /oidcclient/jwk_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package oidcclient 6 | 7 | import ( 8 | "fmt" 9 | "net/http" 10 | "net/http/httptest" 11 | "net/url" 12 | "strings" 13 | "testing" 14 | "time" 15 | 16 | "github.com/gorilla/mux" 17 | "github.com/lestrrat-go/jwx/jwk" 18 | ) 19 | 20 | const jwksJSONString = "{\"keys\":[{\"kty\":\"RSA\",\"kid\":\"default-kid-ias\",\"e\":\"AQAB\",\"use\":\"sig\",\"n\":\"AJtUGmczI7RHx3Ypqxz9_9mK_tc-vOXojlJcMm0VRvYvMLIDlIfj1BrkC_IYLpS2Vl1OTG8AS0xAgBDEG3EUzVU6JZKuIuuxD-iXrBySBQA2ytTYtCrjHD7osji7wyogxDJ2BtVz9191gjX7AlU_WKFPpViK2a_2bCL0K4vI3M6-EZMp20wbD2gDsoD1JYqag66WnTDtZqJjQm3mv6Ohj59_C8RMOtPSLX4AxoS-n_8lYneaRc2UFm_vZepgricMNIZ4TuoLekb_fDlg7cvRtH61gD8hH7iFvQfpkf9rxoclPSG21qbxV4svUVW27DOd_Ewo3eSRdnSb8ctuGnXQuKE=\"}]}" 21 | 22 | func TestProviderJSON_assertMandatoryFieldsPresent(t *testing.T) { 23 | type fields struct { 24 | Issuer string 25 | JWKsURL string 26 | } 27 | tests := []struct { 28 | name string 29 | fields fields 30 | wantErr bool 31 | }{ 32 | { 33 | name: "all present", 34 | fields: fields{ 35 | Issuer: "https://mytenant.accounts400.ondemand.com", 36 | JWKsURL: "https://mytenant.accounts400.ondemand.com/oauth2/certs", 37 | }, 38 | wantErr: false, 39 | }, { 40 | name: "issuer missing", 41 | fields: fields{ 42 | JWKsURL: "https://mytenant.accounts400.ondemand.com/oauth2/certs", 43 | }, 44 | wantErr: true, 45 | }, { 46 | name: "jwks missing", 47 | fields: fields{ 48 | Issuer: "https://mytenant.accounts400.ondemand.com", 49 | }, 50 | wantErr: true, 51 | }, 52 | } 53 | for _, tt := range tests { 54 | t.Run(tt.name, func(t *testing.T) { 55 | p := ProviderJSON{ 56 | Issuer: tt.fields.Issuer, 57 | JWKsURL: tt.fields.JWKsURL, 58 | } 59 | if err := p.assertMandatoryFieldsPresent(); (err != nil) != tt.wantErr { 60 | t.Errorf("assertMandatoryFieldsPresent() error = %v, wantErr %v", err, tt.wantErr) 61 | } 62 | }) 63 | } 64 | } 65 | 66 | func TestOIDCTenant_ReadJWKs(t *testing.T) { 67 | type fields struct { 68 | Duration time.Duration 69 | Client ClientInfo 70 | ExpectedErrorMsg string 71 | } 72 | tests := []struct { 73 | name string 74 | fields fields 75 | wantErr bool 76 | wantProviderJSON bool 77 | }{ 78 | { 79 | name: "read from cache with accepted client credentials", 80 | fields: fields{ 81 | Duration: 2 * time.Second, 82 | Client: ClientInfo{"client-id", "app-tid", "azp"}, 83 | }, 84 | wantErr: false, 85 | wantProviderJSON: false, 86 | }, { 87 | name: "read from cache with invalid client credentials", 88 | fields: fields{ 89 | Duration: 2 * time.Second, 90 | Client: ClientInfo{"invalid-client-id", "invalid-app-tid", "invalid-azp"}, 91 | ExpectedErrorMsg: "client credentials: {ClientID:invalid-client-id AppTID:invalid-app-tid Azp:invalid-azp} " + 92 | "are not accepted by the identity service", 93 | }, 94 | wantErr: true, 95 | wantProviderJSON: false, 96 | }, { 97 | name: "read token endpoint with invalid client_id", 98 | fields: fields{ 99 | Duration: 2 * time.Second, 100 | Client: ClientInfo{"invalid-client-id", "app-tid", "azp"}, 101 | ExpectedErrorMsg: "error updating JWKs: failed to fetch jwks from remote for client credentials " + 102 | "{ClientID:invalid-client-id AppTID:app-tid Azp:azp}: ({\"msg\":\"Invalid x-client_id or x-app_tid provided\"})", 103 | }, 104 | wantErr: true, 105 | wantProviderJSON: true, 106 | }, { 107 | name: "read token endpoint with invalid app_tid", 108 | fields: fields{ 109 | Duration: 2 * time.Second, 110 | Client: ClientInfo{"client-id", "invalid-app-tid", "azp"}, 111 | ExpectedErrorMsg: "error updating JWKs: failed to fetch jwks from remote for client credentials " + 112 | "{ClientID:client-id AppTID:invalid-app-tid Azp:azp}: ({\"msg\":\"Invalid x-client_id or x-app_tid provided\"})", 113 | }, 114 | wantErr: true, 115 | wantProviderJSON: true, 116 | }, { 117 | name: "read token endpoint with invalid azp", 118 | fields: fields{ 119 | Duration: 2 * time.Second, 120 | Client: ClientInfo{"client-id", "app-tid", "invalid-azp"}, 121 | ExpectedErrorMsg: "error updating JWKs: failed to fetch jwks from remote for client credentials " + 122 | "{ClientID:client-id AppTID:app-tid Azp:invalid-azp}: ({\"msg\":\"Invalid x-azp provided\"})", 123 | }, 124 | wantErr: true, 125 | wantProviderJSON: true, 126 | }, { 127 | name: "read from token keys endpoint with accepted client credentials", 128 | fields: fields{ 129 | Duration: 0, 130 | Client: ClientInfo{"client-id", "app-tid", "azp"}, 131 | }, 132 | wantErr: false, 133 | wantProviderJSON: true, 134 | }, { 135 | name: "read from token keys endpoint with denied client credentials", 136 | fields: fields{ 137 | Duration: 0, 138 | Client: ClientInfo{"invalid-client-id", "invalid-app-tid", "invalid-azp"}, 139 | ExpectedErrorMsg: "error updating JWKs: failed to fetch jwks from remote " + 140 | "for client credentials {ClientID:invalid-client-id AppTID:invalid-app-tid Azp:invalid-azp}", 141 | }, 142 | wantErr: true, 143 | wantProviderJSON: true, 144 | }, { 145 | name: "read from token keys endpoint with accepted client credentials provoking parsing error", 146 | fields: fields{ 147 | Duration: 0, 148 | Client: ClientInfo{ClientID: "provide-invalidJWKS"}, 149 | ExpectedErrorMsg: "error updating JWKs: failed to parse JWK set: failed to unmarshal JWK set", 150 | }, 151 | wantErr: true, // as jwks endpoint returns no JSON 152 | wantProviderJSON: true, 153 | }, { 154 | name: "read from token keys endpoint with deleted client credentials", 155 | fields: fields{ 156 | Duration: 0, 157 | Client: ClientInfo{"deleted-client-id", "deleted-app-tid", "deleted-azp"}, 158 | ExpectedErrorMsg: "error updating JWKs: failed to fetch jwks from remote for " + 159 | "client credentials {ClientID:deleted-client-id AppTID:deleted-app-tid Azp:deleted-azp}", 160 | }, 161 | wantErr: true, 162 | wantProviderJSON: true, 163 | }, 164 | } 165 | 166 | router := NewRouter() 167 | localServer := httptest.NewServer(router) 168 | defer localServer.Close() 169 | 170 | for _, tt := range tests { 171 | t.Run(tt.name, func(t *testing.T) { 172 | var providerJSON ProviderJSON 173 | if tt.wantProviderJSON { 174 | localServerURL, _ := url.Parse(localServer.URL) 175 | providerJSON.JWKsURL = fmt.Sprintf("%s/oauth2/certs", localServerURL) 176 | } 177 | jwksJSON, _ := jwk.ParseString(jwksJSONString) 178 | tenant := OIDCTenant{ 179 | jwksExpiry: time.Now().Add(tt.fields.Duration), 180 | acceptedClients: map[ClientInfo]bool{ 181 | {ClientID: "client-id", AppTID: "app-tid", Azp: "azp"}: true, 182 | {ClientID: "deleted-client-id", AppTID: "deleted-app-tid", Azp: "deleted-azp"}: true, 183 | {ClientID: "invalid-client-id", AppTID: "invalid-app-tid", Azp: "invalid-azp"}: false, 184 | }, 185 | httpClient: http.DefaultClient, 186 | jwks: jwksJSON, 187 | ProviderJSON: providerJSON, 188 | } 189 | jwks, err := tenant.GetJWKs(tt.fields.Client) 190 | if tt.wantErr { 191 | if err == nil { 192 | t.Errorf("GetJWKs() does not provide error = %v, tenantCredentials %+v", err, tt.fields.Client) 193 | } 194 | if !strings.HasPrefix(err.Error(), tt.fields.ExpectedErrorMsg) { 195 | t.Errorf("GetJWKs() does not provide expected error message = %v", err.Error()) 196 | } 197 | } else if jwks == nil { 198 | t.Errorf("GetJWKs() returns nil = %v, tenantCredentials %+v", err, tt.fields.Client) 199 | } 200 | }) 201 | } 202 | } 203 | 204 | func NewRouter() (r *mux.Router) { 205 | r = mux.NewRouter() 206 | r.HandleFunc("/oauth2/certs", ReturnJWKS).Methods(http.MethodGet).Headers(clientIDHeader, "client-id", appTIDHeader, "app-tid", azpHeader, "azp") 207 | r.HandleFunc("/oauth2/certs", ReturnInvalidClient).Methods(http.MethodGet).Headers(clientIDHeader, "invalid-client-id") 208 | r.HandleFunc("/oauth2/certs", ReturnInvalidClient).Methods(http.MethodGet).Headers(appTIDHeader, "invalid-app-tid") 209 | r.HandleFunc("/oauth2/certs", ReturnInvalidClient).Methods(http.MethodGet).Headers(azpHeader, "invalid-azp") 210 | r.HandleFunc("/oauth2/certs", ReturnInvalidHeaders).Methods(http.MethodGet).Headers(clientIDHeader, "deleted-client-id", appTIDHeader, "deleted-app-tid", azpHeader, "deleted-azp") 211 | r.HandleFunc("/oauth2/certs", ReturnInvalidJWKS).Methods(http.MethodGet).Headers(clientIDHeader, "provide-invalidJWKS") 212 | return r 213 | } 214 | 215 | func ReturnJWKS(w http.ResponseWriter, _ *http.Request) { 216 | _, _ = w.Write([]byte(jwksJSONString)) 217 | } 218 | 219 | func ReturnInvalidJWKS(w http.ResponseWriter, _ *http.Request) { 220 | _, _ = w.Write([]byte("\"kid\":\"default-kid-ias\"")) 221 | } 222 | 223 | func ReturnInvalidHeaders(w http.ResponseWriter, _ *http.Request) { 224 | w.WriteHeader(400) 225 | } 226 | 227 | func ReturnInvalidClient(w http.ResponseWriter, r *http.Request) { 228 | w.WriteHeader(400) 229 | w.Header().Set("Content-Type", "application/json") 230 | if r.Header.Get(azpHeader) == "invalid-azp" { 231 | _, _ = w.Write([]byte(`{"msg":"Invalid x-azp provided"}`)) 232 | } else { 233 | _, _ = w.Write([]byte(`{"msg":"Invalid x-client_id or x-app_tid provided"}`)) 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /oidcclient/oidcClient.md: -------------------------------------------------------------------------------- 1 | ### TODO: Describe package tasks -------------------------------------------------------------------------------- /sample/manifest.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 1 3 | applications: 4 | - name: go-sec-test 5 | path: ../ 6 | memory: 256MB 7 | disk_quota: 256MB 8 | instances: 1 9 | env: 10 | GO_INSTALL_PACKAGE_SPEC: github.com/sap/cloud-security-client-go/sample 11 | buildpacks: 12 | - go_buildpack 13 | services: 14 | - ias-test # cf create-service identity application ias-test --wait -------------------------------------------------------------------------------- /sample/middleware.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package main 6 | 7 | import ( 8 | "fmt" 9 | "log" 10 | "net/http" 11 | "os" 12 | "time" 13 | 14 | "github.com/gorilla/handlers" 15 | 16 | "github.com/gorilla/mux" 17 | 18 | "github.com/sap/cloud-security-client-go/auth" 19 | "github.com/sap/cloud-security-client-go/env" 20 | ) 21 | 22 | // Main class for demonstration purposes. 23 | func main() { 24 | r := mux.NewRouter() 25 | 26 | config, err := env.ParseIdentityConfig() 27 | if err != nil { 28 | panic(err) 29 | } 30 | authMiddleware := auth.NewMiddleware(config, auth.Options{}) 31 | r.Use(authMiddleware.AuthenticationHandler) // force oauth2 bearer token flow 32 | r.HandleFunc("/auth", parseToken).Methods(http.MethodGet) 33 | 34 | address := ":" + os.Getenv("PORT") 35 | if address == "" { 36 | address = ":8080" 37 | } 38 | server := &http.Server{ 39 | Addr: address, 40 | ReadHeaderTimeout: 5 * time.Second, 41 | Handler: handlers.LoggingHandler(os.Stdout, r), 42 | } 43 | log.Println("Starting server on address", address) 44 | err = server.ListenAndServe() 45 | if err != nil { 46 | panic(err) 47 | } 48 | } 49 | 50 | func parseToken(w http.ResponseWriter, r *http.Request) { 51 | user, ok := auth.TokenFromCtx(r) 52 | if ok { 53 | _, _ = fmt.Fprintf(w, "Hello world!\nYou're logged in as %s", user.Email()) 54 | } else { 55 | _, _ = fmt.Fprintf(w, "Missing token in context") 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /testutil/testdata/privateTestingKey.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA TESTING KEY----- 2 | MIIEowIBAAKCAQEAsPnoGUOnrpiSqt4XynxA+HRP7S+BSObI6qJ7fQAVSPtRkqso 3 | tWxQYLEYzNEx5ZSHTGypibVsJylvCfuToDTfMul8b/CZjP2Ob0LdpYrNH6l5hvFE 4 | 89FU1nZQF15oVLOpUgA7wGiHuEVawrGfey92UE68mOyUVXGweJIVDdxqdMoPvNNU 5 | l86BU02vlBiESxOuox+dWmuVV7vfYZ79Toh/LUK43YvJh+rhv4nKuF7iHjVjBd9s 6 | B6iDjj70HFldzOQ9r8SRI+9NirupPTkF5AKNe6kUhKJ1luB7S27ZkvB3tSTT3P59 7 | 3VVJvnzOjaA1z6Cz+4+eRvcysqhrRgFlwI9TEwIDAQABAoIBAEEYiyDP29vCzx/+ 8 | dS3LqnI5BjUuJhXUnc6AWX/PCgVAO+8A+gZRgvct7PtZb0sM6P9ZcLrweomlGezI 9 | FrL0/6xQaa8bBr/ve/a8155OgcjFo6fZEw3Dz7ra5fbSiPmu4/b/kvrg+Br1l77J 10 | aun6uUAs1f5B9wW+vbR7tzbT/mxaUeDiBzKpe15GwcvbJtdIVMa2YErtRjc1/5B2 11 | BGVXyvlJv0SIlcIEMsHgnAFOp1ZgQ08aDzvilLq8XVMOahAhP1O2A3X8hKdXPyrx 12 | IVWE9bS9ptTo+eF6eNl+d7htpKGEZHUxinoQpWEBTv+iOoHsVunkEJ3vjLP3lyI/ 13 | fY0NQ1ECgYEA3RBXAjgvIys2gfU3keImF8e/TprLge1I2vbWmV2j6rZCg5r/AS0u 14 | pii5CvJ5/T5vfJPNgPBy8B/yRDs+6PJO1GmnlhOkG9JAIPkv0RBZvR0PMBtbp6nT 15 | Y3yo1lwamBVBfY6rc0sLTzosZh2aGoLzrHNMQFMGaauORzBFpY5lU50CgYEAzPHl 16 | u5DI6Xgep1vr8QvCUuEesCOgJg8Yh1UqVoY/SmQh6MYAv1I9bLGwrb3WW/7kqIoD 17 | fj0aQV5buVZI2loMomtU9KY5SFIsPV+JuUpy7/+VE01ZQM5FdY8wiYCQiVZYju9X 18 | Wz5LxMNoz+gT7pwlLCsC4N+R8aoBk404aF1gum8CgYAJ7VTq7Zj4TFV7Soa/T1eE 19 | k9y8a+kdoYk3BASpCHJ29M5R2KEA7YV9wrBklHTz8VzSTFTbKHEQ5W5csAhoL5Fo 20 | qoHzFFi3Qx7MHESQb9qHyolHEMNx6QdsHUn7rlEnaTTyrXh3ifQtD6C0yTmFXUIS 21 | CW9wKApOrnyKJ9nI0HcuZQKBgQCMtoV6e9VGX4AEfpuHvAAnMYQFgeBiYTkBKltQ 22 | XwozhH63uMMomUmtSG87Sz1TmrXadjAhy8gsG6I0pWaN7QgBuFnzQ/HOkwTm+qKw 23 | AsrZt4zeXNwsH7QXHEJCFnCmqw9QzEoZTrNtHJHpNboBuVnYcoueZEJrP8OnUG3r 24 | UjmopwKBgAqB2KYYMUqAOvYcBnEfLDmyZv9BTVNHbR2lKkMYqv5LlvDaBxVfilE0 25 | 2riO4p6BaAdvzXjKeRrGNEKoHNBpOSfYCOM16NjL8hIZB1CaV3WbT5oY+jp7Mzd5 26 | 7d56RZOE+ERK2uz/7JX9VSsM/LbH9pJibd4e8mikDS9ntciqOH/3 27 | -----END RSA TESTING KEY----- -------------------------------------------------------------------------------- /testutil/token.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package testutil 6 | 7 | import ( 8 | "crypto/x509" 9 | _ "embed" 10 | "encoding/pem" 11 | "fmt" 12 | "strings" 13 | 14 | "github.com/lestrrat-go/jwx/jwa" 15 | "github.com/lestrrat-go/jwx/jwt" 16 | 17 | "github.com/sap/cloud-security-client-go/auth" 18 | ) 19 | 20 | //go:embed testdata/privateTestingKey.pem 21 | var dummyKey string 22 | 23 | // NewTokenFromClaims creates a Token from claims. !!! WARNING !!! No validation done when creating a Token this way. Use only in tests! 24 | func NewTokenFromClaims(claims map[string]interface{}) (auth.Token, error) { 25 | jwtToken := jwt.New() 26 | for key, value := range claims { 27 | err := jwtToken.Set(key, value) 28 | if err != nil { 29 | return auth.Token{}, err 30 | } 31 | } 32 | 33 | block, _ := pem.Decode([]byte(strings.ReplaceAll(dummyKey, "TESTING KEY", "PRIVATE KEY"))) 34 | if block == nil { 35 | return auth.Token{}, fmt.Errorf("failed to parse PEM block containing dummyKey") 36 | } 37 | rsaKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) 38 | if err != nil { 39 | return auth.Token{}, fmt.Errorf("unable to create mock server: error generating rsa key: %w", err) 40 | } 41 | 42 | signedJwt, err := jwt.Sign(jwtToken, jwa.RS256, rsaKey) 43 | if err != nil { 44 | return auth.Token{}, fmt.Errorf("error signing token: %w", err) 45 | } 46 | 47 | return auth.NewToken(string(signedJwt)) 48 | } 49 | -------------------------------------------------------------------------------- /testutil/token_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package testutil 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/google/uuid" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestNewTokenFromClaims(t *testing.T) { 15 | userUUID := uuid.NewString() 16 | m := map[string]interface{}{"user_uuid": userUUID} 17 | token, err := NewTokenFromClaims(m) 18 | assert.NoError(t, err) 19 | 20 | assert.Equal(t, userUUID, token.UserUUID(), "UserUUID() got = %v, want %v", token.UserUUID(), userUUID) 21 | } 22 | -------------------------------------------------------------------------------- /tokenclient/README.md: -------------------------------------------------------------------------------- 1 | # Go Token Client 2 | This ``tokenclient`` module provides slim client to call ``/oauth2/token`` identity service endpoints as specified [here](https://docs.cloudfoundry.org/api/uaa/version/74.1.0/index.html#token). Furthermore, it introduces a new API to support the following token flow: 3 | 4 | * **Client Credentials Flow**. 5 | The Client Credentials ([RFC 6749, section 4.4](https://tools.ietf.org/html/rfc6749#section-4.4)) is used by clients to obtain an access token outside of the context of a user. It is used for non interactive applications (a CLI, a batch job, or for service-2-service communication) where the token is issued to the client application itself, instead of an end user for accessing resources without principal propagation. 6 | 7 | ## Initialization 8 | Instantiate TokenFlows which makes by default use of a simple `http.Client`, which should NOT be used in production. 9 | 10 | ```go 11 | config, err := env.ParseIdentityConfig() 12 | if err != nil { 13 | panic(err) 14 | } 15 | 16 | tokenFlows, err := tokenclient.NewTokenFlows(config, tokenclient.Options{HTTPClient: }) 17 | if err != nil { 18 | panic(err) 19 | } 20 | ``` 21 | 22 | ## Get TokenFlows from middleware 23 | In case you leverage `auth.NewMiddleware`, you can also get an initialized TokenFlows from there: 24 | 25 | ```go 26 | tokenFlows, err := authMiddleware.GetTokenFlows() 27 | if err != nil { 28 | panic(err) 29 | } 30 | ``` 31 | 32 | ## Usage 33 | The TokenFlows allows applications to easily create and execute each flow. 34 | 35 | ### Client Credentials Token Flow 36 | Obtain a client credentials token: 37 | 38 | ````go 39 | params := map[string]string{ 40 | "resource": "urn:sap:identity:consumer:clientid:<>", 41 | } 42 | customerTenantUrl := oidcToken.Issuer() 43 | encodedToken, err := tokenFlows.ClientCredentials(context.TODO(), customerTenantUrl, tokenclient.RequestOptions{Params: params}) 44 | if err != nil { 45 | log.Fatal(err) 46 | } 47 | 48 | // optionally you can parse the token to access its claims 49 | token, e := auth.NewToken(encodedToken) 50 | if e != nil { 51 | log.Fatal(err) 52 | } 53 | ```` 54 | In the above sample the ``resource`` parameter specifies the consumer's client id the token is targeted at. 55 | 56 | ## Outlook: Cache 57 | 58 | The `TokenFlows` will cache tokens internally. 59 | 60 | -------------------------------------------------------------------------------- /tokenclient/tokenFlows.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2021 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | package tokenclient 5 | 6 | import ( 7 | "context" 8 | "encoding/json" 9 | "fmt" 10 | "io" 11 | "log" 12 | "net/http" 13 | "net/url" 14 | "strings" 15 | "time" 16 | 17 | "github.com/patrickmn/go-cache" 18 | 19 | "github.com/sap/cloud-security-client-go/env" 20 | "github.com/sap/cloud-security-client-go/httpclient" 21 | ) 22 | 23 | // Options allows configuration http(s) client 24 | type Options struct { 25 | HTTPClient *http.Client // Default: basic http.Client with a timeout of 10 seconds and allowing 50 idle connections 26 | } 27 | 28 | // RequestOptions allows to configure the token request 29 | type RequestOptions struct { 30 | // Request parameters that shall be overwritten or added to the payload 31 | Params map[string]string 32 | // Token Endpoint overwrites the default used /oauth2/token 33 | TokenEndpoint string 34 | } 35 | 36 | // TokenFlows setup once per application. 37 | type TokenFlows struct { 38 | identity env.Identity 39 | Options Options 40 | cache *cache.Cache 41 | } 42 | 43 | type request struct { 44 | http.Request 45 | key string 46 | } 47 | 48 | func (r *request) cacheKey() (string, error) { 49 | if r.key == "" { 50 | bodyReader, err := r.GetBody() 51 | if err != nil { 52 | return "", fmt.Errorf("unexpected error, can't read request body: %w", err) 53 | } 54 | params, err := io.ReadAll(bodyReader) 55 | if err != nil { 56 | return "", fmt.Errorf("unexpected error, can't read request body: %w", err) 57 | } 58 | r.key = fmt.Sprintf("%v?%v", r.URL, string(params)) 59 | } 60 | return r.key, nil 61 | } 62 | 63 | // RequestFailedError represents a HTTP server error 64 | type RequestFailedError struct { 65 | // StatusCode of failed request 66 | StatusCode int 67 | url url.URL 68 | errTxt string 69 | } 70 | 71 | // Error initializes RequestFailedError 72 | func (e *RequestFailedError) Error() string { 73 | return fmt.Sprintf("request to '%v' failed with status code '%v' and payload: '%v'", e.url.String(), e.StatusCode, e.errTxt) 74 | } 75 | 76 | type tokenResponse struct { 77 | Token string `json:"access_token"` 78 | } 79 | 80 | const ( 81 | tokenEndpoint string = "/oauth2/token" //nolint:gosec 82 | grantTypeParameter string = "grant_type" 83 | grantTypeClientCredentials string = "client_credentials" 84 | clientIDParameter string = "client_id" 85 | clientSecretParameter string = "client_secret" 86 | ) 87 | 88 | // NewTokenFlows initializes token flows 89 | // 90 | // identity provides credentials and url to authenticate client with identity service 91 | // options specifies rest client including tls config. 92 | // Note: Setup of default tls config is not supported for windows os. Module crypto/x509 supports SystemCertPool with go 1.18 (https://go-review.googlesource.com/c/go/+/353589/) 93 | func NewTokenFlows(identity env.Identity, options Options) (*TokenFlows, error) { 94 | t := TokenFlows{ 95 | identity: identity, 96 | Options: options, 97 | cache: cache.New(15*time.Minute, 10*time.Minute), 98 | } 99 | if options.HTTPClient == nil { 100 | tlsConfig, err := httpclient.DefaultTLSConfig(identity) 101 | if err != nil { 102 | return nil, err 103 | } 104 | t.Options.HTTPClient = httpclient.DefaultHTTPClient(tlsConfig) 105 | } 106 | return &t, nil 107 | } 108 | 109 | // ClientCredentials implements the client credentials flow (RFC 6749, section 4.4). 110 | // Clients obtain an access token outside the context of a user. 111 | // It is used for non-interactive applications (a CLI, a batch job, or for service-2-service communication) where the token is issued to the application itself, 112 | // instead of an end user for accessing resources without principal propagation. 113 | // 114 | // ctx carries the request context like the deadline or other values that should be shared across API boundaries. 115 | // customerTenantURL like "https://custom.accounts400.ondemand.com" gives the host of the customers ias tenant 116 | // options allows to provide additional request parameters 117 | func (t *TokenFlows) ClientCredentials(ctx context.Context, customerTenantURL string, options RequestOptions) (string, error) { 118 | data := url.Values{} 119 | data.Set(clientIDParameter, t.identity.GetClientID()) 120 | if t.identity.GetClientSecret() != "" { 121 | data.Set(clientSecretParameter, t.identity.GetClientSecret()) 122 | } 123 | for name, value := range options.Params { 124 | data.Set(name, value) // potentially overwrites data which was set before 125 | } 126 | data.Set(grantTypeParameter, grantTypeClientCredentials) 127 | targetURL, err := t.getURL(customerTenantURL, options) 128 | if err != nil { 129 | return "", err 130 | } 131 | r, err := httpclient.NewRequestWithUserAgent(ctx, http.MethodPost, targetURL, strings.NewReader(data.Encode())) // URL-encoded payload 132 | if err != nil { 133 | return "", fmt.Errorf("error performing client credentials flow: %w", err) 134 | } 135 | r.Header.Set("Content-Type", "application/x-www-form-urlencoded") 136 | 137 | return t.getOrRequestToken(request{Request: *r}) 138 | } 139 | 140 | func (t *TokenFlows) getURL(customerTenantURL string, options RequestOptions) (string, error) { 141 | customURL, err := url.Parse(customerTenantURL) 142 | if err == nil && customURL.Host != "" { 143 | endpoint := tokenEndpoint 144 | if options.TokenEndpoint != "" { 145 | endpoint = options.TokenEndpoint 146 | } 147 | return "https://" + customURL.Host + endpoint, nil 148 | } 149 | if !strings.HasPrefix(customerTenantURL, "http") { 150 | return "", fmt.Errorf("customer tenant url '%v' is not a valid url: Trying to parse a hostname without a scheme is invalid", customerTenantURL) 151 | } 152 | return "", fmt.Errorf("customer tenant url '%v' can't be parsed: %w", customerTenantURL, err) 153 | } 154 | 155 | func (t *TokenFlows) getOrRequestToken(r request) (string, error) { 156 | // token cached? 157 | cachedToken := t.readFromCache(&r) 158 | if cachedToken != "" { 159 | return cachedToken, nil 160 | } 161 | 162 | // request token 163 | var tokenRes tokenResponse 164 | err := t.performRequest(r, &tokenRes) 165 | if err != nil { 166 | return "", err 167 | } 168 | if tokenRes.Token == "" { 169 | return "", fmt.Errorf("error parsing requested client credentials token: no 'access_token' property provided") 170 | } 171 | 172 | // cache and return retrieved token 173 | t.writeToCache(r, tokenRes.Token) 174 | return tokenRes.Token, err 175 | } 176 | 177 | func (t *TokenFlows) readFromCache(r *request) string { 178 | cacheKey, err := r.cacheKey() 179 | if err != nil { 180 | return "" 181 | } 182 | cachedEncodedToken, found := t.cache.Get(cacheKey) 183 | if !found { 184 | return "" 185 | } 186 | return fmt.Sprintf("%v", cachedEncodedToken) 187 | } 188 | 189 | func (t *TokenFlows) writeToCache(r request, token string) { 190 | cacheKey, err := r.cacheKey() 191 | if err != nil { 192 | log.Fatalf("Write to Cache is skipped. Unexpected error to determine cache key: %s", err.Error()) 193 | return 194 | } 195 | t.cache.SetDefault(cacheKey, token) 196 | } 197 | 198 | func (t *TokenFlows) performRequest(r request, v interface{}) error { 199 | res, err := t.Options.HTTPClient.Do(&r.Request) 200 | if err != nil { 201 | return fmt.Errorf("request to '%v' failed: %w", r.URL, err) 202 | } 203 | defer res.Body.Close() 204 | if res.StatusCode != http.StatusOK { 205 | body, _ := io.ReadAll(res.Body) 206 | return &RequestFailedError{res.StatusCode, *r.URL, string(body)} 207 | } 208 | if err = json.NewDecoder(res.Body).Decode(v); err != nil { 209 | return fmt.Errorf("error parsing response from %v: %w", r.URL, err) 210 | } 211 | return nil 212 | } 213 | -------------------------------------------------------------------------------- /tokenclient/tokenFlows_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2021 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | package tokenclient 5 | 6 | import ( 7 | "bytes" 8 | "context" 9 | "encoding/json" 10 | "errors" 11 | "net/http" 12 | "net/http/httptest" 13 | "testing" 14 | "time" 15 | 16 | "github.com/gorilla/mux" 17 | "github.com/sap/cloud-security-client-go/env" 18 | "github.com/sap/cloud-security-client-go/httpclient" 19 | "github.com/sap/cloud-security-client-go/mocks" 20 | "github.com/stretchr/testify/assert" 21 | ) 22 | 23 | var tokenRequestHandlerHitCounter int 24 | var dummyToken = "eyJhbGciOiJIUzI1NiJ9.e30.ZRrHA1JJJW8opsbCGfG_HACGpVUMN_a9IV7pAx_Zmeo" //nolint:gosec 25 | 26 | var clientSecretConfig = &env.DefaultIdentity{ 27 | ClientID: "09932670-9440-445d-be3e-432a97d7e2ef", 28 | ClientSecret: "[the_CLIENT.secret:3[/abc", 29 | } 30 | 31 | var mTLSConfig = &env.DefaultIdentity{ 32 | Certificate: "theCertificate", 33 | Key: "theCertificateKey", 34 | } 35 | 36 | func TestNewTokenFlows_setupDefaultHttpsClientFails(t *testing.T) { 37 | tokenFlows, err := NewTokenFlows(mTLSConfig, Options{}) 38 | assert.Nil(t, tokenFlows) 39 | assertError(t, "error creating x509 key pair for DefaultTLSConfig", err) 40 | } 41 | 42 | func TestClientCredentialsTokenFlow_FailsWithTimeout(t *testing.T) { 43 | server := setupNewTLSServer(t, tokenHandler) 44 | defer server.Close() 45 | tokenFlows, _ := NewTokenFlows(mTLSConfig, Options{HTTPClient: server.Client()}) 46 | 47 | timeout, cancelFunc := context.WithTimeout(context.Background(), 0*time.Second) 48 | defer cancelFunc() 49 | _, err := tokenFlows.ClientCredentials(timeout, server.URL, RequestOptions{}) 50 | assertError(t, context.DeadlineExceeded.Error(), err) 51 | } 52 | 53 | func TestClientCredentialsTokenFlow_FailsNoData(t *testing.T) { 54 | server := setupNewTLSServer(t, func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("no json")) }) 55 | defer server.Close() 56 | tokenFlows, _ := NewTokenFlows(mTLSConfig, Options{HTTPClient: server.Client()}) 57 | 58 | _, err := tokenFlows.ClientCredentials(context.TODO(), server.URL, RequestOptions{}) 59 | assertError(t, "error parsing response from https://127.0.0.1", err) 60 | } 61 | 62 | func TestClientCredentialsTokenFlow_FailsNoJson(t *testing.T) { 63 | server := setupNewTLSServer(t, func(w http.ResponseWriter, r *http.Request) {}) 64 | defer server.Close() 65 | tokenFlows, _ := NewTokenFlows(mTLSConfig, Options{HTTPClient: server.Client()}) 66 | 67 | _, err := tokenFlows.ClientCredentials(context.TODO(), server.URL, RequestOptions{}) 68 | assertError(t, "error parsing response from https://127.0.0.1", err) 69 | } 70 | 71 | func TestClientCredentialsTokenFlow_FailsUnexpectedJson(t *testing.T) { 72 | server := setupNewTLSServer(t, func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("{\"a\":\"b\"}")) }) 73 | defer server.Close() 74 | tokenFlows, _ := NewTokenFlows(mTLSConfig, Options{HTTPClient: server.Client()}) 75 | 76 | _, err := tokenFlows.ClientCredentials(context.TODO(), server.URL, RequestOptions{}) 77 | assertError(t, "error parsing requested client credentials token: no 'access_token' property provided", err) 78 | } 79 | 80 | func TestClientCredentialsTokenFlow_FailsWithUnauthenticated(t *testing.T) { 81 | server := setupNewTLSServer(t, func(w http.ResponseWriter, r *http.Request) { 82 | w.WriteHeader(401) 83 | w.Write([]byte("unauthenticated client")) //nolint:errcheck 84 | tokenRequestHandlerHitCounter++ 85 | }) 86 | defer server.Close() 87 | tokenFlows, _ := NewTokenFlows(mTLSConfig, Options{HTTPClient: server.Client()}) 88 | 89 | _, err := tokenFlows.ClientCredentials(context.TODO(), server.URL, RequestOptions{}) 90 | assertError(t, "failed with status code '401' and payload: 'unauthenticated client'", err) 91 | var requestFailed *RequestFailedError 92 | if !errors.As(err, &requestFailed) || requestFailed.StatusCode != 401 { 93 | assert.Fail(t, "error not of type ClientError") 94 | } 95 | assert.Equal(t, 1, tokenRequestHandlerHitCounter) 96 | assert.Equal(t, 0, tokenFlows.cache.ItemCount()) 97 | 98 | _, _ = tokenFlows.ClientCredentials(context.TODO(), server.URL, RequestOptions{}) 99 | assert.Equal(t, 2, tokenRequestHandlerHitCounter) 100 | } 101 | 102 | func TestClientCredentialsTokenFlow_FailsWithCustomerUrlWithoutScheme(t *testing.T) { 103 | server := setupNewTLSServer(t, tokenHandler) 104 | defer server.Close() 105 | tokenFlows, _ := NewTokenFlows(clientSecretConfig, Options{HTTPClient: server.Client()}) 106 | 107 | _, err := tokenFlows.ClientCredentials(context.TODO(), "some-domain.de/with/a/path", RequestOptions{}) 108 | assertError(t, "customer tenant url 'some-domain.de/with/a/path' is not a valid url", err) 109 | assertError(t, "Trying to parse a hostname without a scheme is invalid", err) 110 | } 111 | 112 | func TestClientCredentialsTokenFlow_FailsWithInvalidCustomerUrl(t *testing.T) { 113 | server := setupNewTLSServer(t, tokenHandler) 114 | defer server.Close() 115 | tokenFlows, _ := NewTokenFlows(clientSecretConfig, Options{HTTPClient: server.Client()}) 116 | 117 | _, err := tokenFlows.ClientCredentials(context.TODO(), "https://some-domain.de\abc", RequestOptions{}) 118 | assertError(t, "customer tenant url 'https://some-domain.de\abc' can't be parsed", err) 119 | assertError(t, "parse \"https://some-domain.de\\abc\"", err) 120 | } 121 | 122 | func TestClientCredentialsTokenFlow_Succeeds(t *testing.T) { 123 | server := setupNewTLSServer(t, tokenHandler) 124 | tokenFlows, _ := NewTokenFlows(&env.DefaultIdentity{ 125 | ClientID: "09932670-9440-445d-be3e-432a97d7e2ef"}, Options{HTTPClient: server.Client()}) 126 | 127 | token, err := tokenFlows.ClientCredentials(context.TODO(), server.URL, RequestOptions{}) 128 | assertToken(t, dummyToken, token, err) 129 | } 130 | 131 | func TestClientCredentialsTokenFlow_SucceedsWithCustomEndpoint(t *testing.T) { 132 | server := setupNewTLSServer(t, tokenHandler) 133 | tokenFlows, _ := NewTokenFlows(&env.DefaultIdentity{ 134 | ClientID: "09932670-9440-445d-be3e-432a97d7e2ef"}, Options{HTTPClient: server.Client()}) 135 | 136 | token, err := tokenFlows.ClientCredentials(context.TODO(), server.URL, RequestOptions{TokenEndpoint: "/oauth/token"}) 137 | assertToken(t, dummyToken, token, err) 138 | cachedToken, ok := tokenFlows.cache.Get(server.URL + "/oauth/token?client_id=09932670-9440-445d-be3e-432a97d7e2ef&grant_type=client_credentials") 139 | assert.True(t, ok) 140 | assert.Equal(t, dummyToken, cachedToken) 141 | } 142 | 143 | func TestClientCredentialsTokenFlow_ReadFromCache(t *testing.T) { 144 | server := setupNewTLSServer(t, tokenHandler) 145 | tokenFlows, _ := NewTokenFlows(&env.DefaultIdentity{ 146 | ClientID: "09932670-9440-445d-be3e-432a97d7e2ef"}, Options{HTTPClient: server.Client()}) 147 | 148 | assert.Equal(t, 0, tokenRequestHandlerHitCounter) 149 | assert.Equal(t, 0, tokenFlows.cache.ItemCount()) 150 | 151 | token, err := tokenFlows.ClientCredentials(context.TODO(), server.URL, RequestOptions{}) 152 | assert.Equal(t, 1, tokenRequestHandlerHitCounter) 153 | assert.Equal(t, 1, tokenFlows.cache.ItemCount()) 154 | assertToken(t, dummyToken, token, err) 155 | 156 | token, err = tokenFlows.ClientCredentials(context.TODO(), server.URL, RequestOptions{}) 157 | assert.Equal(t, 1, tokenRequestHandlerHitCounter) 158 | assert.Equal(t, 1, tokenFlows.cache.ItemCount()) 159 | assertToken(t, dummyToken, token, err) 160 | cachedToken, ok := tokenFlows.cache.Get(server.URL + "/oauth2/token?client_id=09932670-9440-445d-be3e-432a97d7e2ef&grant_type=client_credentials") 161 | assert.True(t, ok) 162 | assert.Equal(t, dummyToken, cachedToken) 163 | } 164 | 165 | func TestClientCredentialsTokenFlow_UsingMockServer_Succeeds(t *testing.T) { 166 | mockServer, err := mocks.NewOIDCMockServer() 167 | assert.NoError(t, err) 168 | tokenFlows, _ := NewTokenFlows(&env.DefaultIdentity{ 169 | ClientID: mockServer.Config.ClientID}, Options{HTTPClient: mockServer.Server.Client()}) 170 | 171 | token, err := tokenFlows.ClientCredentials(context.TODO(), mockServer.Server.URL, RequestOptions{}) 172 | assertToken(t, "eyJhbGciOiJIUzI1NiJ9.e30.ZRrHA1JJJW8opsbCGfG_HACGpVUMN_a9IV7pAx_Zmeo", token, err) 173 | } 174 | 175 | func setupNewTLSServer(t *testing.T, f func(http.ResponseWriter, *http.Request)) *httptest.Server { 176 | r := mux.NewRouter() 177 | r.Use(verifyUserAgent) 178 | r.HandleFunc("/oauth2/token", f).Methods(http.MethodPost).Headers("Content-Type", "application/x-www-form-urlencoded") 179 | r.HandleFunc("/oauth/token", f).Methods(http.MethodPost).Headers("Content-Type", "application/x-www-form-urlencoded") 180 | 181 | t.Cleanup(func() { 182 | tokenRequestHandlerHitCounter = 0 183 | }) 184 | return httptest.NewTLSServer(r) 185 | } 186 | 187 | func verifyUserAgent(next http.Handler) http.Handler { 188 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 189 | if r.Header.Get("User-Agent") != httpclient.UserAgent { 190 | w.WriteHeader(http.StatusBadRequest) 191 | _, _ = w.Write([]byte("wrong user agent, expected: " + httpclient.UserAgent)) 192 | } 193 | next.ServeHTTP(w, r) 194 | }) 195 | } 196 | 197 | // tokenHandler is the http handler which serves the /oauth2/token endpoint. 198 | func tokenHandler(w http.ResponseWriter, r *http.Request) { 199 | buf := new(bytes.Buffer) 200 | _, _ = buf.ReadFrom(r.Body) 201 | newStr := buf.String() 202 | if newStr == "client_id=09932670-9440-445d-be3e-432a97d7e2ef&grant_type=client_credentials" { 203 | payload, _ := json.Marshal(tokenResponse{ 204 | Token: dummyToken, 205 | }) 206 | _, _ = w.Write(payload) 207 | } 208 | tokenRequestHandlerHitCounter++ 209 | } 210 | 211 | func assertToken(t assert.TestingT, expectedToken, actualToken string, actualError error) { 212 | assert.NoError(t, actualError) 213 | assert.NotEmpty(t, actualToken) 214 | assert.Equal(t, expectedToken, actualToken) 215 | } 216 | 217 | func assertError(t assert.TestingT, expectedErrorMsg string, actualError error) { 218 | assert.Error(t, actualError) 219 | assert.Contains(t, actualError.Error(), expectedErrorMsg) 220 | } 221 | --------------------------------------------------------------------------------