├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── codeql-analysis.yml │ ├── fuzz.yml │ ├── go.yml │ └── make-gen-delta.yml ├── .gitignore ├── .go-version ├── CHANGELOG.md ├── CODEOWNERS ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── docs.go ├── docs_test.go ├── go.mod ├── go.sum ├── jwt ├── README.md ├── algs.go ├── algs_test.go ├── docs.go ├── docs_test.go ├── jwt.go ├── jwt_test.go ├── keyset.go └── keyset_test.go ├── ldap ├── Makefile ├── README.md ├── client.go ├── client_exported_test.go ├── client_test.go ├── config.go ├── conn_test.go ├── error.go ├── examples │ └── cli │ │ ├── .gitignore │ │ ├── README.md │ │ ├── go.mod │ │ ├── go.sum │ │ ├── local-ldap-config.json │ │ ├── main.go │ │ └── start-local-ldap.sh ├── go.mod ├── go.sum └── options.go ├── oidc ├── README.md ├── access_token.go ├── access_token_test.go ├── algs.go ├── callback │ ├── README.md │ ├── authcode.go │ ├── authcode_test.go │ ├── docs.go │ ├── docs_test.go │ ├── implicit.go │ ├── implicit_test.go │ ├── request_reader.go │ ├── request_reader_test.go │ ├── response_func.go │ └── testing.go ├── clientassertion │ ├── algorithms.go │ ├── client_assertion.go │ ├── client_assertion_test.go │ ├── error.go │ ├── example_test.go │ └── options.go ├── config.go ├── config_test.go ├── display.go ├── docs.go ├── docs_test.go ├── error.go ├── examples │ ├── cli │ │ ├── .gitignore │ │ ├── README.md │ │ ├── main.go │ │ └── responses.go │ └── spa │ │ ├── .gitignore │ │ ├── README.md │ │ ├── main.go │ │ ├── request_cache.go │ │ ├── route_callback.go │ │ ├── route_login.go │ │ └── route_success.go ├── id.go ├── id_test.go ├── id_token.go ├── id_token_test.go ├── internal │ ├── base62 │ │ ├── base62.go │ │ └── base62_test.go │ └── strutils │ │ ├── strutils.go │ │ └── strutils_test.go ├── options.go ├── options_test.go ├── pkce_verifier.go ├── pkce_verifier_test.go ├── prompt.go ├── provider.go ├── provider_test.go ├── refresh_token.go ├── refresh_token_test.go ├── request.go ├── request_test.go ├── testing.go ├── testing_provider.go ├── testing_provider_test.go ├── token.go └── token_test.go ├── saml ├── README.md ├── authn_request.go ├── authn_request.gohtml ├── authn_request_test.go ├── config.go ├── config_test.go ├── demo │ ├── .gitignore │ └── main.go ├── error.go ├── go.mod ├── go.sum ├── handler │ ├── acs.go │ ├── metadata.go │ ├── post_binding.go │ └── redirect_binding.go ├── is_nil.go ├── is_nil_test.go ├── models │ ├── core │ │ ├── common.go │ │ ├── fixtures │ │ │ └── response.xml.go │ │ ├── request.go │ │ ├── response.go │ │ └── response_test.go │ └── metadata │ │ ├── common.go │ │ ├── duration.go │ │ ├── duration_test.go │ │ ├── entity_descriptor.go │ │ ├── idp_sso_descriptor.go │ │ ├── idp_sso_descriptor_test.go │ │ ├── sp_sso_descriptor.go │ │ └── sp_sso_descriptor_test.go ├── options.go ├── response.go ├── response_test.go ├── sp.go ├── sp_test.go └── test │ └── provider.go ├── tools └── tools.go └── util └── util.go /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '24 18 * * 6' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'go' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4.0.0 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@c6c77c8c2d62cfd5b2e8d548817fd3d1582ac744 # codeql-bundle-v2.14.5 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@c6c77c8c2d62cfd5b2e8d548817fd3d1582ac744 # codeql-bundle-v2.14.5 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@c6c77c8c2d62cfd5b2e8d548817fd3d1582ac744 # codeql-bundle-v2.14.5 72 | -------------------------------------------------------------------------------- /.github/workflows/fuzz.yml: -------------------------------------------------------------------------------- 1 | # This is based on https://github.com/jidicula/go-fuzz-action/blob/main/action.yml 2 | # whose license has been reproduced here. 3 | # MIT License 4 | 5 | # Copyright (c) 2022 Johanan Idicula 6 | 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | 14 | # The above copyright notice and this permission notice shall be included in all 15 | # copies or substantial portions of the Software. 16 | 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | # SOFTWARE. 24 | name: Go fuzz test 25 | on: 26 | push: 27 | branches: [ main ] 28 | pull_request: 29 | branches: [ main ] 30 | jobs: 31 | fuzz-lexer-test: 32 | name: Fuzz escapeValue(...) test 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 36 | - name: Determine Go version 37 | id: get-go-version 38 | # We use .go-version as our source of truth for current Go 39 | # version, because "goenv" can react to it automatically. 40 | run: | 41 | echo "Building with Go $(cat .go-version)" 42 | echo "go-version=$(cat .go-version)" >> "$GITHUB_OUTPUT" 43 | - uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 44 | with: 45 | go-version: "${{ steps.get-go-version.outputs.go-version }}" 46 | - shell: bash 47 | run: cd ldap; go test -fuzz=Fuzz_EscapeValue -fuzztime=30s 48 | - name: Upload fuzz failure seed corpus as run artifact 49 | if: failure() 50 | uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 51 | with: 52 | name: fuzz-corpus 53 | path: ./ldap/testdata/fuzz 54 | - name: Output message 55 | if: failure() 56 | shell: bash 57 | run: | 58 | echo -e "Fuzz test failed on commit ${{ env.SHA }}. To troubleshoot locally, use the [GitHub CLI](https://cli.github.com) to download the seed corpus with\n\ngh run download ${{ github.run_id }} -n fuzz-corpus\n" 59 | 60 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | name: Build 12 | strategy: 13 | fail-fast: true 14 | matrix: 15 | go: 16 | - stable 17 | - oldstable 18 | platform: 19 | - ubuntu-latest # can not run in windows OS 20 | runs-on: ${{ matrix.platform }} 21 | steps: 22 | - name: Set up Go 1.x 23 | uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 24 | with: 25 | go-version: ${{ matrix.go }} 26 | 27 | - name: Check out code into the Go module directory 28 | uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4.0.0 29 | 30 | - name: go mod package cache 31 | uses: actions/cache@v4 32 | with: 33 | path: ~/go/pkg/mod 34 | key: ${{ runner.os }}-go-${{ matrix.go }}-${{ hashFiles('tests/go.mod') }} 35 | 36 | - name: Build 37 | run: | 38 | set -e 39 | exit_status= 40 | for f in $(find . -name go.mod) 41 | do 42 | pushd $(dirname $f) > /dev/null 43 | go build ./... || exit_status=$? 44 | popd > /dev/null 45 | done 46 | exit $status 47 | 48 | - name: Test 49 | run: | 50 | set -e 51 | exit_status= 52 | for f in $(find . -name go.mod) 53 | do 54 | pushd $(dirname $f) > /dev/null 55 | go test -test.v ./... || exit_status=$? 56 | popd > /dev/null 57 | done 58 | exit $exit_status 59 | -------------------------------------------------------------------------------- /.github/workflows/make-gen-delta.yml: -------------------------------------------------------------------------------- 1 | name: "make-gen-delta" 2 | on: 3 | - workflow_dispatch 4 | - push 5 | - workflow_call 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | make-gen-delta: 12 | name: "Check for uncommitted changes from make gen" 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 16 | with: 17 | fetch-depth: '0' 18 | - name: Determine Go version 19 | id: get-go-version 20 | # We use .go-version as our source of truth for current Go 21 | # version, because "goenv" can react to it automatically. 22 | run: | 23 | echo "Building with Go $(cat .go-version)" 24 | echo "go-version=$(cat .go-version)" >> "$GITHUB_OUTPUT" 25 | - name: Set up Go 26 | uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 27 | with: 28 | go-version: "${{ steps.get-go-version.outputs.go-version }}" 29 | - name: Running go mod tidy 30 | run: | 31 | go mod tidy 32 | - name: Install Dependencies 33 | run: | 34 | make tools 35 | - name: Running make gen 36 | run: | 37 | make gen 38 | - name: Check for changes 39 | run: | 40 | git diff --exit-code 41 | git status --porcelain 42 | test -z "$(git status --porcelain)" 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Folders 2 | _obj 3 | _test 4 | .cover 5 | 6 | # IntelliJ IDEA project files 7 | .idea 8 | *.ipr 9 | *.iml 10 | *.iws 11 | 12 | ### Logs ### 13 | *.log 14 | logs/ 15 | 16 | ### direnv ### 17 | .envrc 18 | .direnv/ 19 | 20 | ### Temp directories ### 21 | tmp/ 22 | temp/ 23 | 24 | ### Visual Studio ### 25 | .vscode/ 26 | 27 | ### macOS ### 28 | # General 29 | .DS_Store 30 | .AppleDouble 31 | .LSOverride 32 | 33 | # Icon must end with two \r 34 | Icon 35 | 36 | 37 | # Thumbnails 38 | ._* 39 | 40 | ### Git ### 41 | # Created by git for backups. To disable backups in Git: 42 | # $ git config --global mergetool.keepBackup false 43 | *.orig 44 | 45 | # Created by git when using merge tools for conflicts 46 | *.BACKUP.* 47 | *.BASE.* 48 | *.LOCAL.* 49 | *.REMOTE.* 50 | *_BACKUP_*.txt 51 | *_BASE_*.txt 52 | *_LOCAL_*.txt 53 | *_REMOTE_*.txt 54 | 55 | ### Go ### 56 | # Binaries for programs and plugins 57 | *.exe 58 | *.exe~ 59 | *.dll 60 | *.so 61 | *.dylib 62 | 63 | # Test binary, built with `go test -c` 64 | *.test 65 | 66 | # Output of the go coverage tool, specifically when used with LiteIDE 67 | *.out 68 | 69 | ### Tags ### 70 | # Ignore tags created by etags, ctags, gtags (GNU global) and cscope 71 | TAGS 72 | .TAGS 73 | !TAGS/ 74 | tags 75 | .tags 76 | !tags/ 77 | gtags.files 78 | GTAGS 79 | GRTAGS 80 | GPATH 81 | GSYMS 82 | cscope.files 83 | cscope.out 84 | cscope.in.out 85 | cscope.po.out 86 | 87 | ### Vagrant ### 88 | # General 89 | .vagrant/ 90 | 91 | # Log files (if you are creating logs in debug mode, uncomment this) 92 | # *.log 93 | 94 | ### Vagrant Patch ### 95 | *.box 96 | 97 | ### Vim ### 98 | # Swap 99 | [._]*.s[a-v][a-z] 100 | [._]*.sw[a-p] 101 | [._]s[a-rt-v][a-z] 102 | [._]ss[a-gi-z] 103 | [._]sw[a-p] 104 | 105 | # Session 106 | Session.vim 107 | Sessionx.vim 108 | 109 | # Temporary 110 | .netrwhist 111 | *~ 112 | 113 | # Auto-generated tag files 114 | # Persistent undo 115 | [._]*.un~ 116 | 117 | # Compilation outputs 118 | main 119 | /pkg/ 120 | /bin/ 121 | update-ui-assets* 122 | /plugins/kms/assets/ 123 | /plugins/host/assets/boundary-plugin* 124 | 125 | # Test config file 126 | test*.hcl 127 | 128 | # vim: set filetype=conf : 129 | -------------------------------------------------------------------------------- /.go-version: -------------------------------------------------------------------------------- 1 | 1.23.0 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # cap CHANGELOG 2 | 3 | Canonical reference for changes, improvements, and bugfixes for cap. 4 | 5 | ## Next 6 | 7 | ## 0.9.0 8 | 9 | * feat (oidc): add WithClientAssertionJWT to enable "private key JWT" ([PR #155](https://github.com/hashicorp/cap/pull/155)) 10 | 11 | ## 0.8.0 12 | 13 | * feat (oidc): add WithVerifier ([PR #141](https://github.com/hashicorp/cap/pull/141)) 14 | * feat (ldap): add an option to enable sAMAccountname logins when upndomain is set ([PR #146](https://github.com/hashicorp/cap/pull/146)) 15 | * feat (saml): enhancing signature validation in SAML Response ([PR #144](https://github.com/hashicorp/cap/pull/144)) 16 | * chore: update dependencies in pkgs: cap, cap/ldap, cap/saml ([PR 17 | #147](https://github.com/hashicorp/cap/pull/147), [PR 18 | #148](https://github.com/hashicorp/cap/pull/148), [PR 19 | #149](https://github.com/hashicorp/cap/pull/149)) 20 | * chore: update CODEOWNERS ([PR 21 | #142](https://github.com/hashicorp/cap/pull/142),[PR 22 | #143](https://github.com/hashicorp/cap/pull/143) ) 23 | 24 | ## 0.7.0 25 | 26 | * Add ability to the SAML test provider to create signed SAML responses by 27 | @hcjulz ([PR: 135](https://github.com/hashicorp/cap/pull/135)) 28 | * Bump golang.org/x/net from 0.22.0 to 0.23.0 by @dependabot ([PR #136](https://github.com/hashicorp/cap/pull/136)) 29 | * feat (config): add support for a http.RoundTripper by @jimlambrt ([PR #137](https://github.com/hashicorp/cap/pull/137)) 30 | * chore: update deps by @jimlambrt ([PR #138](https://github.com/hashicorp/cap/pull/138)) 31 | 32 | ## 0.6.0 33 | 34 | * Add case insensitive user attribute keys configs for LDAP by @jasonodonnell in https://github.com/hashicorp/cap/pull/132 35 | * chore (oidc, jwt, ldap): update deps by @jimlambrt in **https**://github.com/hashicorp/cap/pull/133 36 | * Add empty anonymous group search configs by @jasonodonnell in https://github.com/hashicorp/cap/pull/134 37 | 38 | ## 0.5.0 39 | 40 | ### Improvements 41 | 42 | * JWT 43 | * Adds ability to specify more than one `KeySet` used for token validation (https://github.com/hashicorp/cap/pull/128) 44 | 45 | ## 0.4.1 46 | 47 | ### Bug fixes 48 | 49 | * SAML 50 | * Truncate issue instant to microseconds to support Microsoft Entra ID enterprise applications (https://github.com/hashicorp/cap/pull/126) 51 | 52 | ## 0.4.0 53 | 54 | ### Features 55 | 56 | * SAML 57 | * Adds support for SAML authentication (https://github.com/hashicorp/cap/pull/99). 58 | 59 | ### Improvements 60 | 61 | * LDAP 62 | * Add worker pool for LDAP token group lookups ([**PR**](https://github.com/hashicorp/cap/pull/98)) 63 | 64 | ## 0.3.4 65 | 66 | ### Bug fixes 67 | 68 | * OIDC/examples/cli 69 | * Use free port if OIDC_PORT is not set for the example ([**PR**](https://github.com/hashicorp/cap/pull/79)) 70 | 71 | 72 | ## 0.3.3 73 | ### Bug fixes: 74 | * LDAP 75 | * A more compete fix for `escapeValue(...)` and we've stopped exporting it ([**PR**](https://github.com/hashicorp/cap/pull/78)) 76 | ## 0.3.2 77 | 78 | ### Bug fixes: 79 | * Address a set of LDAP issues ([**PR**](https://github.com/hashicorp/cap/pull/77)): 80 | * Properly escape user filters when using UPN domains 81 | * Increase max tls to 1.3 82 | * Improve `EscapeValue(...)` 83 | * Use text template for rendering filters 84 | 85 | ## 0.3.1 86 | 87 | ### Bug Fixes 88 | * Fixes integer overflow in `auth_time` claim validation when compiled for 32-bit 89 | architecture ([**PR**](https://github.com/hashicorp/cap/pull/76)) 90 | 91 | ## 0.3.0 92 | #### OIDC 93 | * Add `ProviderConfig` which creates a provider that doesn't support 94 | OIDC discovery. It's probably better to use NewProvider(...) with discovery 95 | whenever possible ([**PR**](https://github.com/hashicorp/cap/pull/57) and [issue](https://github.com/hashicorp/cap/issues/55)). 96 | * Improve WSL detection ([**PR**](https://github.com/hashicorp/cap/pull/51)) 97 | * Add option to allow all of IAT, NBF, and EXP to be missing 98 | ([**PR**](https://github.com/hashicorp/cap/pull/50)) 99 | * Validate sub and aud are present in an id_token ([**PR**](https://github.com/hashicorp/cap/pull/48)) 100 | 101 | #### LDAP 102 | * Add better (more consistent) timeouts ([**PR**](https://github.com/hashicorp/cap/pull/61)) 103 | * Add better error msgs on failed search queries ([**PR**](https://github.com/hashicorp/cap/pull/60)) 104 | * Add new config fields for including/excluding user attrs ([**PR**](https://github.com/hashicorp/cap/pull/59)) 105 | * Add `WithUserAttributes(...)` option to the ldap package that allows callers 106 | to request that attributes be returned for the authenticating user ([**PR**](https://github.com/hashicorp/cap/pull/58)) 107 | 108 | 109 | 110 | ## 0.2.0 (2022/04/08) 111 | * Add support for LDAP/AD authentication ([**PR**](https://github.com/hashicorp/cap/pull/47)) 112 | 113 | 114 | ## 0.1.1 (2021/06/24) 115 | 116 | ### Bug Fixes 117 | 118 | * oidc: remove extra unused parameter to Info logging in TestProvider.startCachedCodesCleanupTicking 119 | ([PR](https://github.com/hashicorp/cap/pull/42)). 120 | 121 | ## 0.1.0 (2021/05/21) 122 | 123 | v0.1.0 is the first release. As a result there are no changes, improvements, or bugfixes from past versions. 124 | 125 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @hashicorp/boundary 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to CAP 2 | 3 | Thank you for contributing to CAP! Here you can find common questions around 4 | reporting issues and opening pull requests to our project. 5 | 6 | ## Issue Reporting 7 | ### Reporting Security Related Vulnerabilities 8 | 9 | We take CAP's security and our users' trust very seriously. If you believe you 10 | have found a security issue in CAP, please responsibly disclose by contacting us 11 | at security@hashicorp.com. Do not open an issue on our GitHub issue tracker if 12 | you believe you've found a security related issue, thank you! 13 | 14 | ### Bug Fixes 15 | 16 | If you believe you found a bug with CAP, please: 17 | 18 | 1. Build from the latest `main` HEAD commit to attempt to reproduce the issue. 19 | It's possible we've already fixed the bug, and this is a first good step to 20 | ensuring that's not the case. 21 | 1. Ensure a similar ticket is not already opened by searching our opened issues 22 | on GitHub. 23 | 24 | 25 | Once you've verified the above, feel free to open a bug fix issue template type 26 | from our [issue selector](https://github.com/hashicorp/cap/issues/new/choose) 27 | and we'll do our best to triage it as quickly as possible. 28 | 29 | ## Pull Requests 30 | 31 | ### New Features & Improvements 32 | 33 | Before writing a line of code, please ask us about a potential improvement or 34 | feature that you want to write into CAP. We may already be working on it; 35 | even if we aren't, we need to ensure that both the feature and its proposed 36 | implementation is aligned with our road map, vision, and standards for the 37 | project. We're happy to help walk through that via a [feature request 38 | issue](https://github.com/hashicorp/cap/issues/new/choose). 39 | 40 | ### Submitting a New Pull Request 41 | 42 | When submitting a pull request, please ensure: 43 | 44 | 1. You've added a changelog line clearly describing the new addition under the 45 | correct changelog sub-section. 46 | 1. You've followed the above guidelines for contributing to CAP. 47 | 48 | Once you open your PR, please allow us a couple of days to comment, request 49 | changes, or approve your PR. Once a PR is created, please do not rebase your PR 50 | branch, since rebasing would make it more difficult to review requested PR 51 | changes. Accepted PR commits will be squashed into a single commit when 52 | they are merged. 53 | 54 | Thank you for your contribution! 55 | 56 | ## Changelog 57 | 58 | The changelog is updated by PR contributors. Each contribution to CAP should 59 | include a changelog update at the contributor or reviewer discretion. The 60 | changelog should be updated when the contribution is large enough to warrant it 61 | being called out in the larger release cycle. Enhancements, bug fixes, and other 62 | contributions that practitioners might want to be aware of should exist in the 63 | changelog. 64 | 65 | When contributing to the changelog, follow existing patterns for referencing 66 | PR's, issues or other ancillary context. 67 | 68 | The changelog is broken down into sections: 69 | 70 | ### Next 71 | 72 | The current release cycle. New contributions slated for the next release should 73 | go under this heading. If the contribution is being backported, the inclusion of 74 | the feature in the appropriate release during the backport process is handled 75 | on an as-needed basis. 76 | 77 | ### New and Improved 78 | 79 | Any enhancements, new features, etc fall into this section. 80 | 81 | ### Bug Fixes 82 | 83 | Any bug fixes fall into this section. 84 | 85 | **** -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Format Go files, ignoring files marked as generated through the header defined at 2 | # https://pkg.go.dev/cmd/go#hdr-Generate_Go_files_by_processing_source 3 | .PHONY: fmt 4 | fmt: 5 | gofumpt -w $$(find . -name '*.go') 6 | 7 | .PHONY: gen 8 | gen: fmt copywrite 9 | 10 | .PHONY: copywrite 11 | copywrite: 12 | copywrite headers 13 | 14 | .PHONY: tools 15 | tools: 16 | go generate -tags tools tools/tools.go 17 | go install github.com/hashicorp/copywrite@v0.15.0 -------------------------------------------------------------------------------- /docs.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | // cap (collection of authentication packages) provides a collection of related 5 | // packages which enable support for OIDC, JWT Verification, and Distributed Claims. 6 | // 7 | // See README.md 8 | package cap 9 | -------------------------------------------------------------------------------- /docs_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package cap_test 5 | 6 | import ( 7 | "context" 8 | "encoding/json" 9 | "fmt" 10 | "net/http" 11 | "time" 12 | 13 | "github.com/hashicorp/cap/oidc" 14 | ) 15 | 16 | func Example_oidc() { 17 | ctx := context.Background() 18 | 19 | // Create a new Config 20 | pc, err := oidc.NewConfig( 21 | "http://your-issuer.com/", 22 | "your_client_id", 23 | "your_client_secret", 24 | []oidc.Alg{oidc.RS256}, 25 | []string{"http://your_redirect_url"}, 26 | ) 27 | if err != nil { 28 | // handle error 29 | } 30 | 31 | // Create a provider 32 | p, err := oidc.NewProvider(pc) 33 | if err != nil { 34 | // handle error 35 | } 36 | defer p.Done() 37 | 38 | // Create a Request for a user's authentication attempt that will use the 39 | // authorization code flow. (See NewRequest(...) using the WithPKCE and 40 | // WithImplicit options for creating a Request that uses those flows.) 41 | oidcRequest, err := oidc.NewRequest(2*time.Minute, "http://your_redirect_url/callback") 42 | if err != nil { 43 | // handle error 44 | } 45 | 46 | // Create an auth URL 47 | authURL, err := p.AuthURL(ctx, oidcRequest) 48 | if err != nil { 49 | // handle error 50 | } 51 | fmt.Println("open url to kick-off authentication: ", authURL) 52 | 53 | // Create a http.Handler for OIDC authentication response redirects 54 | callbackHandler := func(w http.ResponseWriter, r *http.Request) { 55 | // Exchange a successful authentication's authorization code and 56 | // authorization state (received in a callback) for a verified Token. 57 | t, err := p.Exchange(ctx, oidcRequest, r.FormValue("state"), r.FormValue("code")) 58 | if err != nil { 59 | // handle error 60 | } 61 | var claims map[string]interface{} 62 | if err := t.IDToken().Claims(&claims); err != nil { 63 | // handle error 64 | } 65 | 66 | // Get the user's claims via the provider's UserInfo endpoint 67 | var infoClaims map[string]interface{} 68 | err = p.UserInfo(ctx, t.StaticTokenSource(), claims["sub"].(string), &infoClaims) 69 | if err != nil { 70 | // handle error 71 | } 72 | resp := struct { 73 | IDTokenClaims map[string]interface{} 74 | UserInfoClaims map[string]interface{} 75 | }{claims, infoClaims} 76 | enc := json.NewEncoder(w) 77 | if err := enc.Encode(resp); err != nil { 78 | // handle error 79 | } 80 | } 81 | http.HandleFunc("/callback", callbackHandler) 82 | } 83 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hashicorp/cap 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/coreos/go-oidc/v3 v3.11.0 7 | github.com/go-jose/go-jose/v3 v3.0.4 8 | github.com/go-jose/go-jose/v4 v4.0.5 9 | github.com/hashicorp/go-cleanhttp v0.5.2 10 | github.com/hashicorp/go-hclog v1.6.3 11 | github.com/hashicorp/go-multierror v1.1.1 12 | github.com/hashicorp/go-uuid v1.0.3 13 | github.com/stretchr/testify v1.10.0 14 | github.com/yhat/scrape v0.0.0-20161128144610-24b7890b0945 15 | golang.org/x/net v0.38.0 16 | golang.org/x/oauth2 v0.21.0 17 | golang.org/x/text v0.23.0 18 | mvdan.cc/gofumpt v0.5.0 19 | ) 20 | 21 | require ( 22 | github.com/davecgh/go-spew v1.1.1 // indirect 23 | github.com/fatih/color v1.17.0 // indirect 24 | github.com/google/go-cmp v0.6.0 // indirect 25 | github.com/hashicorp/errwrap v1.1.0 // indirect 26 | github.com/mattn/go-colorable v0.1.13 // indirect 27 | github.com/mattn/go-isatty v0.0.20 // indirect 28 | github.com/pmezard/go-difflib v1.0.0 // indirect 29 | golang.org/x/crypto v0.36.0 // indirect 30 | golang.org/x/mod v0.17.0 // indirect 31 | golang.org/x/sync v0.12.0 // indirect 32 | golang.org/x/sys v0.31.0 // indirect 33 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect 34 | gopkg.in/yaml.v3 v3.0.1 // indirect 35 | ) 36 | -------------------------------------------------------------------------------- /jwt/README.md: -------------------------------------------------------------------------------- 1 | # jwt 2 | [![Go Reference](https://pkg.go.dev/badge/github.com/hashicorp/cap/jwt.svg)](https://pkg.go.dev/github.com/hashicorp/cap/jwt) 3 | 4 | Package jwt provides signature verification and claims set validation for JSON Web Tokens (JWT) 5 | of the JSON Web Signature (JWS) form. 6 | 7 | Primary types provided by the package: 8 | 9 | * `KeySet`: Represents a set of keys that can be used to verify the signatures of JWTs. 10 | A KeySet is expected to be backed by a set of local or remote keys. 11 | 12 | * `Validator`: Provides signature verification and claims set validation behavior for JWTs. 13 | 14 | * `Expected`: Defines the expected claims values to assert when validating a JWT. 15 | 16 | * `Alg`: Represents asymmetric signing algorithms. 17 | 18 | ### Examples: 19 | 20 | Please see [docs_test.go](./docs_test.go) for additional usage examples. 21 | -------------------------------------------------------------------------------- /jwt/algs.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package jwt 5 | 6 | import "fmt" 7 | 8 | // Alg represents asymmetric signing algorithms 9 | type Alg string 10 | 11 | const ( 12 | // JOSE asymmetric signing algorithm values as defined by RFC 7518. 13 | // 14 | // See: https://tools.ietf.org/html/rfc7518#section-3.1 15 | RS256 Alg = "RS256" // RSASSA-PKCS-v1.5 using SHA-256 16 | RS384 Alg = "RS384" // RSASSA-PKCS-v1.5 using SHA-384 17 | RS512 Alg = "RS512" // RSASSA-PKCS-v1.5 using SHA-512 18 | ES256 Alg = "ES256" // ECDSA using P-256 and SHA-256 19 | ES384 Alg = "ES384" // ECDSA using P-384 and SHA-384 20 | ES512 Alg = "ES512" // ECDSA using P-521 and SHA-512 21 | PS256 Alg = "PS256" // RSASSA-PSS using SHA256 and MGF1-SHA256 22 | PS384 Alg = "PS384" // RSASSA-PSS using SHA384 and MGF1-SHA384 23 | PS512 Alg = "PS512" // RSASSA-PSS using SHA512 and MGF1-SHA512 24 | EdDSA Alg = "EdDSA" // Ed25519 using SHA-512 25 | ) 26 | 27 | var supportedAlgorithms = map[Alg]bool{ 28 | RS256: true, 29 | RS384: true, 30 | RS512: true, 31 | ES256: true, 32 | ES384: true, 33 | ES512: true, 34 | PS256: true, 35 | PS384: true, 36 | PS512: true, 37 | EdDSA: true, 38 | } 39 | 40 | // SupportedSigningAlgorithm returns an error if any of the given Algs 41 | // are not supported signing algorithms. 42 | func SupportedSigningAlgorithm(algs ...Alg) error { 43 | for _, a := range algs { 44 | if !supportedAlgorithms[a] { 45 | return fmt.Errorf("unsupported signing algorithm %q", a) 46 | } 47 | } 48 | return nil 49 | } 50 | -------------------------------------------------------------------------------- /jwt/algs_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package jwt 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestSupportedSigningAlgorithm(t *testing.T) { 13 | type args struct { 14 | algs []Alg 15 | } 16 | tests := []struct { 17 | name string 18 | args args 19 | wantErr bool 20 | }{ 21 | { 22 | name: "supported signing algorithms", 23 | args: args{ 24 | algs: []Alg{RS256, RS384, RS512, ES256, ES384, ES512, PS256, PS384, PS512, EdDSA}, 25 | }, 26 | }, 27 | { 28 | name: "unsupported signing algorithm none", 29 | args: args{ 30 | algs: []Alg{Alg("none")}, 31 | }, 32 | wantErr: true, 33 | }, 34 | } 35 | for _, tt := range tests { 36 | t.Run(tt.name, func(t *testing.T) { 37 | err := SupportedSigningAlgorithm(tt.args.algs...) 38 | if tt.wantErr { 39 | require.Error(t, err) 40 | return 41 | } 42 | require.NoError(t, err) 43 | }) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /jwt/docs.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | /* 5 | Package jwt provides signature verification and claims set validation for JSON Web Tokens (JWT) 6 | of the JSON Web Signature (JWS) form. 7 | 8 | JWT claims set validation provided by the package includes the option to validate 9 | all registered claim names defined in https://tools.ietf.org/html/rfc7519#section-4.1. 10 | 11 | JOSE header validation provided by the the package includes the option to validate the "alg" 12 | (Algorithm) Header Parameter defined in https://tools.ietf.org/html/rfc7515#section-4.1. 13 | 14 | JWT signature verification is supported by providing keys from the following sources: 15 | 16 | - JSON Web Key Set (JWKS) URL 17 | - OIDC Discovery mechanism 18 | - Local public keys 19 | 20 | JWT signature verification supports the following asymmetric algorithms as defined in 21 | https://www.rfc-editor.org/rfc/rfc7518.html#section-3.1: 22 | 23 | - RS256: RSASSA-PKCS1-v1_5 using SHA-256 24 | - RS384: RSASSA-PKCS1-v1_5 using SHA-384 25 | - RS512: RSASSA-PKCS1-v1_5 using SHA-512 26 | - ES256: ECDSA using P-256 and SHA-256 27 | - ES384: ECDSA using P-384 and SHA-384 28 | - ES512: ECDSA using P-521 and SHA-512 29 | - PS256: RSASSA-PSS using SHA-256 and MGF1 with SHA-256 30 | - PS384: RSASSA-PSS using SHA-384 and MGF1 with SHA-384 31 | - PS512: RSASSA-PSS using SHA-512 and MGF1 with SHA-512 32 | - EdDSA: Ed25519 using SHA-512 33 | */ 34 | package jwt 35 | -------------------------------------------------------------------------------- /jwt/docs_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package jwt_test 5 | 6 | import ( 7 | "context" 8 | "crypto" 9 | "crypto/rand" 10 | "crypto/rsa" 11 | "fmt" 12 | "log" 13 | 14 | "github.com/hashicorp/cap/jwt" 15 | ) 16 | 17 | func ExampleValidator_Validate() { 18 | ctx := context.Background() 19 | 20 | keySet, err := jwt.NewJSONWebKeySet(ctx, "your_jwks_url", "your_jwks_ca_pem") 21 | if err != nil { 22 | log.Fatal(err) 23 | } 24 | 25 | validator, err := jwt.NewValidator(keySet) 26 | if err != nil { 27 | log.Fatal(err) 28 | } 29 | 30 | expected := jwt.Expected{ 31 | Issuer: "your_expected_issuer", 32 | Subject: "your_expected_subject", 33 | ID: "your_expected_jwt_id", 34 | Audiences: []string{"your_expected_audiences"}, 35 | SigningAlgorithms: []jwt.Alg{jwt.RS256}, 36 | } 37 | 38 | token := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJleHBfaXNzIiwiZXhwIjoxNTI2MjM5MDIyfQ.XG1xYJcuPMfgu8xkMzVjkYK2WIUyl4-A1Zq1j4Dfr99-PJUN36ZAgi8Fj08modiexXETrg05MqSxkJAE5Czns1IhqEEypx6xfYHSINp0SLKxBFHPA4BCi0IW83T-e225JjjVEGFR_Wo8QM6Rc-qQVJ9bqwKD4kcbQeMACkgGFcgNurtNkOM9vtOEs0Pe9tb4nHYw4ef1stCytTi9GFZwGoHQf0pjpWCpjlxaFIR4vmHQ4YB3w29o_tKN6zqyA2FITnvkzGnaLvdPecJNskRSCPUTRfYcVVNXCOnCvTdpvwK-c4nCs5yGnw3eeFoT6mhQSp39KYti1MpHNQTYwZrLTA" 39 | claims, err := validator.Validate(ctx, token, expected) 40 | if err != nil { 41 | log.Fatal(err) 42 | } 43 | 44 | fmt.Println(claims) 45 | } 46 | 47 | func ExampleNewJSONWebKeySet() { 48 | ctx := context.Background() 49 | 50 | keySet, err := jwt.NewJSONWebKeySet(ctx, "your_jwks_url", "your_jwks_ca_pem") 51 | if err != nil { 52 | log.Fatal(err) 53 | } 54 | 55 | token := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJleHBfaXNzIiwiZXhwIjoxNTI2MjM5MDIyfQ.XG1xYJcuPMfgu8xkMzVjkYK2WIUyl4-A1Zq1j4Dfr99-PJUN36ZAgi8Fj08modiexXETrg05MqSxkJAE5Czns1IhqEEypx6xfYHSINp0SLKxBFHPA4BCi0IW83T-e225JjjVEGFR_Wo8QM6Rc-qQVJ9bqwKD4kcbQeMACkgGFcgNurtNkOM9vtOEs0Pe9tb4nHYw4ef1stCytTi9GFZwGoHQf0pjpWCpjlxaFIR4vmHQ4YB3w29o_tKN6zqyA2FITnvkzGnaLvdPecJNskRSCPUTRfYcVVNXCOnCvTdpvwK-c4nCs5yGnw3eeFoT6mhQSp39KYti1MpHNQTYwZrLTA" 56 | claims, err := keySet.VerifySignature(ctx, token) 57 | if err != nil { 58 | log.Fatal(err) 59 | } 60 | 61 | fmt.Println(claims) 62 | } 63 | 64 | func ExampleNewOIDCDiscoveryKeySet() { 65 | ctx := context.Background() 66 | 67 | keySet, err := jwt.NewOIDCDiscoveryKeySet(ctx, "your_issuer_url", "your_issuer_ca_pem") 68 | if err != nil { 69 | log.Fatal(err) 70 | } 71 | 72 | token := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJleHBfaXNzIiwiZXhwIjoxNTI2MjM5MDIyfQ.XG1xYJcuPMfgu8xkMzVjkYK2WIUyl4-A1Zq1j4Dfr99-PJUN36ZAgi8Fj08modiexXETrg05MqSxkJAE5Czns1IhqEEypx6xfYHSINp0SLKxBFHPA4BCi0IW83T-e225JjjVEGFR_Wo8QM6Rc-qQVJ9bqwKD4kcbQeMACkgGFcgNurtNkOM9vtOEs0Pe9tb4nHYw4ef1stCytTi9GFZwGoHQf0pjpWCpjlxaFIR4vmHQ4YB3w29o_tKN6zqyA2FITnvkzGnaLvdPecJNskRSCPUTRfYcVVNXCOnCvTdpvwK-c4nCs5yGnw3eeFoT6mhQSp39KYti1MpHNQTYwZrLTA" 73 | claims, err := keySet.VerifySignature(ctx, token) 74 | if err != nil { 75 | log.Fatal(err) 76 | } 77 | 78 | fmt.Println(claims) 79 | } 80 | 81 | func ExampleNewStaticKeySet() { 82 | ctx := context.Background() 83 | 84 | rsaKey, err := rsa.GenerateKey(rand.Reader, 4096) 85 | if err != nil { 86 | log.Fatal(err) 87 | } 88 | 89 | keys := []crypto.PublicKey{ 90 | rsaKey.Public(), 91 | } 92 | 93 | keySet, err := jwt.NewStaticKeySet(keys) 94 | if err != nil { 95 | log.Fatal(err) 96 | } 97 | 98 | token := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJleHBfaXNzIiwiZXhwIjoxNTI2MjM5MDIyfQ.XG1xYJcuPMfgu8xkMzVjkYK2WIUyl4-A1Zq1j4Dfr99-PJUN36ZAgi8Fj08modiexXETrg05MqSxkJAE5Czns1IhqEEypx6xfYHSINp0SLKxBFHPA4BCi0IW83T-e225JjjVEGFR_Wo8QM6Rc-qQVJ9bqwKD4kcbQeMACkgGFcgNurtNkOM9vtOEs0Pe9tb4nHYw4ef1stCytTi9GFZwGoHQf0pjpWCpjlxaFIR4vmHQ4YB3w29o_tKN6zqyA2FITnvkzGnaLvdPecJNskRSCPUTRfYcVVNXCOnCvTdpvwK-c4nCs5yGnw3eeFoT6mhQSp39KYti1MpHNQTYwZrLTA" 99 | claims, err := keySet.VerifySignature(ctx, token) 100 | if err != nil { 101 | log.Fatal(err) 102 | } 103 | 104 | fmt.Println(claims) 105 | } 106 | -------------------------------------------------------------------------------- /ldap/Makefile: -------------------------------------------------------------------------------- 1 | 2 | .PHONY: test 3 | test: 4 | find . -name go.mod -execdir go test -count=1 ./... \; 5 | 6 | .PHONY: build 7 | build: 8 | find . -name go.mod -execdir go build ./... \; 9 | -------------------------------------------------------------------------------- /ldap/conn_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package ldap 5 | 6 | import ( 7 | "testing" 8 | ) 9 | 10 | var testcases = map[string]string{ 11 | "#test": "\\#test", 12 | "test,hello": "test\\,hello", 13 | "test,hel+lo": "test\\,hel\\+lo", 14 | "test\\hello": "test\\\\hello", 15 | " test ": "\\ test \\ ", 16 | "": "", 17 | `\`: `\\`, 18 | "trailing\000": `trailing\00`, 19 | "mid\000dle": `mid\00dle`, 20 | "\000": `\00`, 21 | "multiple\000\000": `multiple\00\00`, 22 | "backlash-before-null\\\000": `backlash-before-null\\\00`, 23 | "trailing\\": `trailing\\`, 24 | "double-escaping\\>": `double-escaping\\\>`, 25 | } 26 | 27 | func Test_EscapeValue(t *testing.T) { 28 | for test, answer := range testcases { 29 | res := escapeValue(test) 30 | if res != answer { 31 | t.Errorf("Failed to escape %q: %q != %q\n", test, res, answer) 32 | } 33 | } 34 | } 35 | 36 | // Fuzz_EscapeValue is only focused on finding panics 37 | func Fuzz_EscapeValue(f *testing.F) { 38 | for tc := range testcases { 39 | f.Add(tc) 40 | } 41 | f.Fuzz(func(t *testing.T, s string) { 42 | _ = escapeValue(s) 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /ldap/error.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package ldap 5 | 6 | import "errors" 7 | 8 | var ( 9 | // ErrUnknown is an unknown/undefined error 10 | ErrUnknown = errors.New("unknown") 11 | 12 | // ErrInvalidParameter is an invalid parameter error 13 | ErrInvalidParameter = errors.New("invalid parameter") 14 | 15 | // ErrInternal is an internal error 16 | ErrInternal = errors.New("internal error") 17 | ) 18 | -------------------------------------------------------------------------------- /ldap/examples/cli/.gitignore: -------------------------------------------------------------------------------- 1 | cli -------------------------------------------------------------------------------- /ldap/examples/cli/README.md: -------------------------------------------------------------------------------- 1 | # cli 2 | 3 | An example LDAP user authentication CLI that also retrieves the user's groups. 4 |
5 | 6 | ## Running the CLI 7 | ``` 8 | go build 9 | ``` 10 | Running the cli requires providing the username for authentication. 11 | ``` 12 | ./cli -username 13 | ``` 14 |
15 | 16 | ### Using the built-in LDAP directory service 17 | 18 | We've add support to use a built in test LDAP directory into the CLI example. 19 | 20 | When you run the cli without specifying a config file, the test directory 21 | will be configured and started on an available localhost port. 22 | 23 | The test directory only allows you to login with one user which is `alice` with a password 24 | of `password`. 25 | 26 | This very simple Test Directory option removes the dependency of 27 | standing up your own Directory, if you just want to run the CLI and see it work. 28 | 29 |
30 | 31 | ### Using your own directory service. 32 | If you wish to use your own directory with the cli, then provide a json 33 | configuration file via `--config 34 | `. 35 | 36 | See the `ldap.ClientConfig` for the available configuration settings. \ 37 | 38 | An example of how this might be done is include, which can be executed by: 39 | * Starting a local openldap service in docker using: `./start-local-ldap.sh` 40 | * Then authenticating via: `./cli --config local-ldap-config.json --username "Hermes Conrad"` then provide the password of `hermes` when 41 | prompted. 42 | 43 |
44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /ldap/examples/cli/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hashicorp/cap/ldap/examples/cli 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/hashicorp/cap/ldap v0.0.0-20240327184157-0d025609db1e 7 | github.com/hashicorp/go-hclog v1.6.2 8 | github.com/hashicorp/go-secure-stdlib/password v0.1.3 9 | github.com/jimlambrt/gldap v0.1.13 10 | ) 11 | 12 | require ( 13 | github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect 14 | github.com/cenkalti/backoff v2.2.1+incompatible // indirect 15 | github.com/davecgh/go-spew v1.1.1 // indirect 16 | github.com/fatih/color v1.16.0 // indirect 17 | github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect 18 | github.com/go-ldap/ldap/v3 v3.4.6 // indirect 19 | github.com/google/uuid v1.6.0 // indirect 20 | github.com/hashicorp/errwrap v1.1.0 // indirect 21 | github.com/hashicorp/go-multierror v1.1.1 // indirect 22 | github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8 // indirect 23 | github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect 24 | github.com/hashicorp/go-secure-stdlib/tlsutil v0.1.3 // indirect 25 | github.com/hashicorp/go-sockaddr v1.0.6 // indirect 26 | github.com/mattn/go-colorable v0.1.13 // indirect 27 | github.com/mattn/go-isatty v0.0.20 // indirect 28 | github.com/mitchellh/mapstructure v1.5.0 // indirect 29 | github.com/pmezard/go-difflib v1.0.0 // indirect 30 | github.com/ryanuber/go-glob v1.0.0 // indirect 31 | github.com/stretchr/testify v1.9.0 // indirect 32 | golang.org/x/crypto v0.36.0 // indirect 33 | golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect 34 | golang.org/x/sys v0.31.0 // indirect 35 | golang.org/x/term v0.30.0 // indirect 36 | gopkg.in/yaml.v3 v3.0.1 // indirect 37 | ) 38 | -------------------------------------------------------------------------------- /ldap/examples/cli/local-ldap-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "_comment": [ 3 | "this is just part of an example way to use your own directory with the cli.", 4 | "See the ldap.ClientConfig for the available configuration settings." 5 | ], 6 | "urls": [ 7 | "ldap://localhost:10389" 8 | ], 9 | "insecure_tls": true, 10 | "discoverdn": true, 11 | "userdn": "ou=people,dc=planetexpress,dc=com" 12 | } -------------------------------------------------------------------------------- /ldap/examples/cli/main.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "context" 8 | "encoding/json" 9 | "flag" 10 | "fmt" 11 | "os" 12 | 13 | "github.com/hashicorp/cap/ldap" 14 | "github.com/hashicorp/go-hclog" 15 | "github.com/hashicorp/go-secure-stdlib/password" 16 | "github.com/jimlambrt/gldap/testdirectory" 17 | ) 18 | 19 | func main() { 20 | // collect the username and password from the cli 21 | username := flag.String("username", "", "username to authenticate") 22 | cfgFilename := flag.String("config", "", "config filename") 23 | flag.Parse() 24 | if *username == "" { 25 | fmt.Fprintf(os.Stderr, "you must specify a --username\n") 26 | return 27 | } 28 | var clientConfig ldap.ClientConfig 29 | if *cfgFilename == "" { 30 | td := startTestDirectory() 31 | defer func() { td.Stop() }() 32 | clientConfig = ldap.ClientConfig{ 33 | URLs: []string{fmt.Sprintf("ldaps://127.0.0.1:%d", td.Port())}, 34 | Certificates: []string{td.Cert()}, 35 | DiscoverDN: true, 36 | UserDN: testdirectory.DefaultUserDN, 37 | GroupDN: testdirectory.DefaultGroupDN, 38 | } 39 | } else { 40 | configFile, err := os.Open(*cfgFilename) 41 | defer configFile.Close() 42 | if err != nil { 43 | fmt.Fprintf(os.Stderr, err.Error()) 44 | return 45 | } 46 | jsonParser := json.NewDecoder(configFile) 47 | if err := jsonParser.Decode(&clientConfig); err != nil { 48 | fmt.Fprintf(os.Stderr, err.Error()) 49 | return 50 | } 51 | } 52 | fmt.Fprintf(os.Stderr, "Enter password: ") 53 | value, err := password.Read(os.Stdin) 54 | fmt.Print("\n") 55 | if err != nil { 56 | fmt.Fprintf(os.Stderr, "An error occurred attempting to read the password. The raw error message is shown below but usually this is because you attempted to pipe a value into the command or you are executing outside of a terminal (TTY). The raw error was:\n\n%s", err.Error()) 57 | return 58 | } 59 | 60 | // create an ldap client for authentication 61 | ctx := context.Background() 62 | client, err := ldap.NewClient(ctx, &clientConfig) 63 | if err != nil { 64 | fmt.Fprintf(os.Stderr, "An error occurred creating the ldap client:\n%s\n", err) 65 | return 66 | } 67 | defer func() { client.Close(ctx) }() 68 | 69 | // authenticate the user 70 | result, err := client.Authenticate(ctx, *username, value, ldap.WithGroups()) 71 | if err != nil { 72 | fmt.Fprintf(os.Stderr, "An error occurred during authentication:\n%s\n", err.Error()) 73 | return 74 | } 75 | 76 | // display the results 77 | if result.Success { 78 | fmt.Fprintf(os.Stdout, "authentication was successful for username: %s\n", *username) 79 | if len(result.Groups) > 0 { 80 | fmt.Fprintf(os.Stdout, "they belong to groups: %s", result.Groups) 81 | } 82 | } 83 | } 84 | 85 | func startTestDirectory() *testdirectory.Directory { 86 | // start a test directory for the example 87 | logger := hclog.New(&hclog.LoggerOptions{ 88 | Name: "testdirectory-logger", 89 | Level: hclog.Error, 90 | }) 91 | t := &testdirectory.Logger{Logger: logger} 92 | td := testdirectory.Start(t, testdirectory.WithLogger(t, logger), testdirectory.WithDefaults(t, &testdirectory.Defaults{AllowAnonymousBind: true})) 93 | td.SetGroups(testdirectory.NewGroup(t, "admin", []string{"alice"})) 94 | td.SetUsers(testdirectory.NewUsers(t, []string{"alice", "bob"})...) 95 | return td 96 | } 97 | -------------------------------------------------------------------------------- /ldap/examples/cli/start-local-ldap.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Copyright (c) HashiCorp, Inc. 3 | # SPDX-License-Identifier: MPL-2.0 4 | 5 | 6 | # this is just part of an example way to use your own directory with the cli. 7 | # 8 | # see: https://github.com/rroemhild/docker-test-openldap 9 | # for more information about this docker file which is running an openldap 10 | # service. The server is initialized with the example domain planetexpress.com 11 | # with data from the Futurama Wiki. 12 | docker run --rm -p 10389:10389 -p 10636:10636 rroemhild/test-openldap -------------------------------------------------------------------------------- /ldap/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hashicorp/cap/ldap 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/go-ldap/ldap/v3 v3.4.6 7 | github.com/hashicorp/go-hclog v1.6.2 8 | github.com/hashicorp/go-multierror v1.1.1 9 | github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 10 | github.com/hashicorp/go-secure-stdlib/tlsutil v0.1.3 11 | github.com/jimlambrt/gldap v0.1.13 12 | github.com/stretchr/testify v1.9.0 13 | ) 14 | 15 | require ( 16 | github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect 17 | github.com/cenkalti/backoff v2.2.1+incompatible // indirect 18 | github.com/davecgh/go-spew v1.1.1 // indirect 19 | github.com/fatih/color v1.16.0 // indirect 20 | github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect 21 | github.com/google/uuid v1.6.0 // indirect 22 | github.com/hashicorp/errwrap v1.1.0 // indirect 23 | github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8 // indirect 24 | github.com/hashicorp/go-sockaddr v1.0.6 // indirect 25 | github.com/mattn/go-colorable v0.1.13 // indirect 26 | github.com/mattn/go-isatty v0.0.20 // indirect 27 | github.com/mitchellh/mapstructure v1.5.0 // indirect 28 | github.com/pmezard/go-difflib v1.0.0 // indirect 29 | github.com/ryanuber/go-glob v1.0.0 // indirect 30 | golang.org/x/crypto v0.36.0 // indirect 31 | golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect 32 | golang.org/x/sys v0.31.0 // indirect 33 | gopkg.in/yaml.v3 v3.0.1 // indirect 34 | ) 35 | -------------------------------------------------------------------------------- /ldap/options.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package ldap 5 | 6 | // Option defines a common functional options type which can be used in a 7 | // variadic parameter pattern. 8 | type Option func(interface{}) 9 | 10 | type configOptions struct { 11 | withURLs []string 12 | withInsecureTLS bool 13 | withTLSMinVersion string 14 | withTLSMaxVersion string 15 | withCertificates []string 16 | withClientTLSCert string 17 | withClientTLSKey string 18 | withGroups bool 19 | withUserAttributes bool 20 | withLowerUserAttributeKeys bool 21 | withEmptyAnonymousGroupSearch bool 22 | } 23 | 24 | func configDefaults() configOptions { 25 | return configOptions{} 26 | } 27 | 28 | // getConfigOpts gets the defaults and applies the opt overrides passed 29 | // in. 30 | func getConfigOpts(opt ...Option) configOptions { 31 | opts := configDefaults() 32 | ApplyOpts(&opts, opt...) 33 | return opts 34 | } 35 | 36 | // ApplyOpts takes a pointer to the options struct as a set of default options 37 | // and applies the slice of opts as overrides. 38 | func ApplyOpts(opts interface{}, opt ...Option) { 39 | for _, o := range opt { 40 | if o == nil { // ignore any nil Options 41 | continue 42 | } 43 | o(opts) 44 | } 45 | } 46 | 47 | // WithURLs provides a set of optional ldap URLs for directory services 48 | func WithURLs(urls ...string) Option { 49 | return func(o interface{}) { 50 | switch v := o.(type) { 51 | case *configOptions: 52 | v.withURLs = urls 53 | } 54 | } 55 | } 56 | 57 | // WithGroups requests that the groups be included in the response. 58 | func WithGroups() Option { 59 | return func(o interface{}) { 60 | switch v := o.(type) { 61 | case *configOptions: 62 | v.withGroups = true 63 | } 64 | } 65 | } 66 | 67 | // WithUserAttributes requests that authenticating user's DN and attributes be 68 | // included in the response. Note: the default password attribute for both 69 | // openLDAP (userPassword) and AD (unicodePwd) will always be excluded. To 70 | // exclude additional attributes see: Config.ExcludedUserAttributes. 71 | func WithUserAttributes() Option { 72 | return func(o interface{}) { 73 | switch v := o.(type) { 74 | case *configOptions: 75 | v.withUserAttributes = true 76 | } 77 | } 78 | } 79 | 80 | // WithLowerUserAttributeKeys returns a User Attribute map where the keys 81 | // are all cast to lower case. This is necessary for some clients, such as Vault, 82 | // where user configured user attribute key names have always been stored lower case. 83 | func WithLowerUserAttributeKeys() Option { 84 | return func(o interface{}) { 85 | switch v := o.(type) { 86 | case *configOptions: 87 | v.withLowerUserAttributeKeys = true 88 | } 89 | } 90 | } 91 | 92 | // WithEmptyAnonymousGroupSearch removes userDN from anonymous group searches. 93 | func WithEmptyAnonymousGroupSearch() Option { 94 | return func(o interface{}) { 95 | switch v := o.(type) { 96 | case *configOptions: 97 | v.withEmptyAnonymousGroupSearch = true 98 | } 99 | } 100 | } 101 | 102 | func withTLSMinVersion(version string) Option { 103 | return func(o interface{}) { 104 | switch v := o.(type) { 105 | case *configOptions: 106 | v.withTLSMinVersion = version 107 | } 108 | } 109 | } 110 | 111 | func withTLSMaxVersion(version string) Option { 112 | return func(o interface{}) { 113 | switch v := o.(type) { 114 | case *configOptions: 115 | v.withTLSMaxVersion = version 116 | } 117 | } 118 | } 119 | 120 | func withInsecureTLS(withInsecure bool) Option { 121 | return func(o interface{}) { 122 | switch v := o.(type) { 123 | case *configOptions: 124 | v.withInsecureTLS = withInsecure 125 | } 126 | } 127 | } 128 | 129 | func withCertificates(cert ...string) Option { 130 | return func(o interface{}) { 131 | switch v := o.(type) { 132 | case *configOptions: 133 | v.withCertificates = cert 134 | } 135 | } 136 | } 137 | 138 | func withClientTLSKey(key string) Option { 139 | return func(o interface{}) { 140 | switch v := o.(type) { 141 | case *configOptions: 142 | v.withClientTLSKey = key 143 | } 144 | } 145 | } 146 | 147 | func withClientTLSCert(cert string) Option { 148 | return func(o interface{}) { 149 | switch v := o.(type) { 150 | case *configOptions: 151 | v.withClientTLSCert = cert 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /oidc/README.md: -------------------------------------------------------------------------------- 1 | # oidc 2 | [![Go Reference](https://pkg.go.dev/badge/github.com/hashicorp/cap/oidc.svg)](https://pkg.go.dev/github.com/hashicorp/cap/oidc) 3 | 4 | oidc is a package for writing clients that integrate with OIDC Providers using 5 | OIDC flows. 6 | 7 | Primary types provided by the package: 8 | 9 | * `Request`: represents one OIDC authentication flow for a user. It contains the 10 | data needed to uniquely represent that one-time flow across the multiple 11 | interactions needed to complete the OIDC flow the user is attempting. All 12 | Requests contain an expiration for the user's OIDC flow. 13 | 14 | * `Token`: represents an OIDC id_token, as well as an Oauth2 access_token and 15 | refresh_token (including the the access_token expiry) 16 | 17 | * `Config`: provides the configuration for a typical 3-legged OIDC 18 | authorization code flow (for example: client ID/Secret, redirectURL, supported 19 | signing algorithms, additional scopes requested, etc) 20 | 21 | * `Provider`: provides integration with an OIDC provider. 22 | The provider provides capabilities like: generating an auth URL, exchanging 23 | codes for tokens, verifying tokens, making user info requests, etc. 24 | 25 | * `Alg`: represents asymmetric signing algorithms 26 | 27 | * `Error`: provides an error and provides the ability to specify an error code, 28 | operation that raised the error, the kind of error, and any wrapped error 29 | 30 | #### [oidc.callback](callback/) 31 | [![Go Reference](https://pkg.go.dev/badge/github.com/hashicorp/cap/oidc/callback.svg)](https://pkg.go.dev/github.com/hashicorp/cap/oidc/callback) 32 | 33 | The callback package includes handlers (http.HandlerFunc) which can be used 34 | for the callback leg an OIDC flow. Callback handlers for both the authorization 35 | code flow (with optional PKCE) and the implicit flow are provided. 36 | 37 |
38 | 39 | ### Examples: 40 | 41 | * [CLI example](examples/cli/) which implements an OIDC 42 | user authentication CLI. 43 | 44 | * [SPA example](examples/spa) which implements an OIDC user 45 | authentication SPA (single page app). 46 | 47 |
48 | 49 | Example of a provider using an authorization code flow: 50 | 51 | ```go 52 | // Create a new provider config 53 | pc, err := oidc.NewConfig( 54 | "http://your-issuer.com/", 55 | "your_client_id", 56 | "your_client_secret", 57 | []oidc.Alg{oidc.RS256}, 58 | []string{"http://your_redirect_url"}, 59 | ) 60 | if err != nil { 61 | // handle error 62 | } 63 | 64 | // Create a provider 65 | p, err := oidc.NewProvider(pc) 66 | if err != nil { 67 | // handle error 68 | } 69 | defer p.Done() 70 | 71 | 72 | // Create a Request for a user's authentication attempt that will use the 73 | // authorization code flow. (See NewRequest(...) using the WithPKCE and 74 | // WithImplicit options for creating a Request that uses those flows.) 75 | oidcRequest, err := oidc.NewRequest(2 * time.Minute, "http://your_redirect_url/callback") 76 | if err != nil { 77 | // handle error 78 | } 79 | 80 | // Create an auth URL 81 | authURL, err := p.AuthURL(context.Background(), oidcRequest) 82 | if err != nil { 83 | // handle error 84 | } 85 | fmt.Println("open url to kick-off authentication: ", authURL) 86 | ``` 87 | 88 | Create a http.Handler for OIDC authentication response redirects. 89 | 90 | ```go 91 | func NewHandler(ctx context.Context, p *oidc.Provider, rw callback.RequestReader) (http.HandlerFunc, error) 92 | if p == nil { 93 | // handle error 94 | } 95 | if rw == nil { 96 | // handle error 97 | } 98 | return func(w http.ResponseWriter, r *http.Request) { 99 | oidcRequest, err := rw.Read(ctx, req.FormValue("state")) 100 | if err != nil { 101 | // handle error 102 | } 103 | // Exchange(...) will verify the tokens before returning. 104 | token, err := p.Exchange(ctx, oidcRequest, req.FormValue("state"), req.FormValue("code")) 105 | if err != nil { 106 | // handle error 107 | } 108 | var claims map[string]interface{} 109 | if err := t.IDToken().Claims(&claims); err != nil { 110 | // handle error 111 | } 112 | 113 | // Get the user's claims via the provider's UserInfo endpoint 114 | var infoClaims map[string]interface{} 115 | err = p.UserInfo(ctx, token.StaticTokenSource(), claims["sub"].(string), &infoClaims) 116 | if err != nil { 117 | // handle error 118 | } 119 | resp := struct { 120 | IDTokenClaims map[string]interface{} 121 | UserInfoClaims map[string]interface{} 122 | }{claims, infoClaims} 123 | enc := json.NewEncoder(w) 124 | if err := enc.Encode(resp); err != nil { 125 | // handle error 126 | } 127 | } 128 | } 129 | ``` 130 | 131 | 132 | -------------------------------------------------------------------------------- /oidc/access_token.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package oidc 5 | 6 | import "encoding/json" 7 | 8 | // AccessToken is an oauth access_token. 9 | type AccessToken string 10 | 11 | // RedactedAccessToken is the redacted string or json for an oauth access_token. 12 | const RedactedAccessToken = "[REDACTED: access_token]" 13 | 14 | // String will redact the token. 15 | func (t AccessToken) String() string { 16 | return RedactedAccessToken 17 | } 18 | 19 | // MarshalJSON will redact the token. 20 | func (t AccessToken) MarshalJSON() ([]byte, error) { 21 | return json.Marshal(RedactedAccessToken) 22 | } 23 | -------------------------------------------------------------------------------- /oidc/access_token_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package oidc 5 | 6 | import ( 7 | "fmt" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestAccessToken_String(t *testing.T) { 15 | t.Parallel() 16 | t.Run("redacted", func(t *testing.T) { 17 | assert := assert.New(t) 18 | const want = RedactedAccessToken 19 | tk := AccessToken("super secret token") 20 | assert.Equalf(want, tk.String(), "AccessToken.String() = %v, want %v", tk.String(), want) 21 | }) 22 | } 23 | 24 | func TestAccessToken_MarshalJSON(t *testing.T) { 25 | t.Parallel() 26 | t.Run("redacted", func(t *testing.T) { 27 | assert, require := assert.New(t), require.New(t) 28 | want := fmt.Sprintf(`"%s"`, RedactedAccessToken) 29 | tk := AccessToken("super secret token") 30 | got, err := tk.MarshalJSON() 31 | require.NoError(err) 32 | assert.Equalf([]byte(want), got, "AccessToken.MarshalJSON() = %s, want %s", got, want) 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /oidc/algs.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package oidc 5 | 6 | // Alg represents asymmetric signing algorithms 7 | type Alg string 8 | 9 | const ( 10 | // JOSE asymmetric signing algorithm values as defined by RFC 7518. 11 | // 12 | // See: https://tools.ietf.org/html/rfc7518#section-3.1 13 | RS256 Alg = "RS256" // RSASSA-PKCS-v1.5 using SHA-256 14 | RS384 Alg = "RS384" // RSASSA-PKCS-v1.5 using SHA-384 15 | RS512 Alg = "RS512" // RSASSA-PKCS-v1.5 using SHA-512 16 | ES256 Alg = "ES256" // ECDSA using P-256 and SHA-256 17 | ES384 Alg = "ES384" // ECDSA using P-384 and SHA-384 18 | ES512 Alg = "ES512" // ECDSA using P-521 and SHA-512 19 | PS256 Alg = "PS256" // RSASSA-PSS using SHA256 and MGF1-SHA256 20 | PS384 Alg = "PS384" // RSASSA-PSS using SHA384 and MGF1-SHA384 21 | PS512 Alg = "PS512" // RSASSA-PSS using SHA512 and MGF1-SHA512 22 | EdDSA Alg = "EdDSA" 23 | ) 24 | 25 | var supportedAlgorithms = map[Alg]bool{ 26 | RS256: true, 27 | RS384: true, 28 | RS512: true, 29 | ES256: true, 30 | ES384: true, 31 | ES512: true, 32 | PS256: true, 33 | PS384: true, 34 | PS512: true, 35 | EdDSA: true, 36 | } 37 | -------------------------------------------------------------------------------- /oidc/callback/README.md: -------------------------------------------------------------------------------- 1 | # callback 2 | [![Go Reference](https://pkg.go.dev/badge/github.com/hashicorp/cap/oidc/callback.svg)](https://pkg.go.dev/github.com/hashicorp/cap/oidc/callback) 3 | 4 | The callback package includes handlers (http.HandlerFunc) which can be used 5 | for the callback leg an OIDC flow. Callback handlers for both the authorization 6 | code flow (with optional PKCE) and the implicit flow are provided. 7 | 8 |
9 | 10 | ### Example snippets... 11 | 12 | ```go 13 | ctx := context.Background() 14 | // Create a new Config 15 | pc, err := oidc.NewConfig( 16 | "http://your-issuer.com/", 17 | "your_client_id", 18 | "your_client_secret", 19 | []oidc.Alg{oidc.RS256}, 20 | []string{"http://your_redirect_url/auth-code-callback", "http://your_redirect_url/implicit-callback"}, 21 | ) 22 | if err != nil { 23 | // handle error 24 | } 25 | 26 | // Create a provider 27 | p, err := oidc.NewProvider(pc) 28 | if err != nil { 29 | // handle error 30 | } 31 | defer p.Done() 32 | 33 | // Create a Request for a user's authentication attempt that will use the 34 | // authorization code flow. (See NewRequest(...) using the WithPKCE and 35 | // WithImplicit options for creating a Request that uses those flows.) 36 | ttl := 2 * time.Minute 37 | authCodeAttempt, err := oidc.NewRequest(ttl, "http://your_redirect_url/auth-code-callback") 38 | if err != nil { 39 | // handle error 40 | } 41 | 42 | // Create a Request for a user's authentication attempt using an implicit 43 | // flow. 44 | implicitAttempt, err := oidc.NewRequest(ttl, "http://your_redirect_url/implicit-callback") 45 | if err != nil { 46 | // handle error 47 | } 48 | 49 | // A function to handle successful attempts from callback. 50 | successFn := func( 51 | state string, 52 | t oidc.Token, 53 | w http.ResponseWriter, 54 | req *http.Request, 55 | ) { 56 | w.WriteHeader(http.StatusOK) 57 | printableToken := fmt.Sprintf("id_token: %s", string(t.IDToken())) 58 | _, _ = w.Write([]byte(printableToken)) 59 | } 60 | 61 | // A function to handle errors and failed attempts from **callback**. 62 | errorFn := func( 63 | state string, 64 | r *callback.AuthenErrorResponse, 65 | e error, 66 | w http.ResponseWriter, 67 | req *http.Request, 68 | ) { 69 | if e != nil { 70 | w.WriteHeader(http.StatusInternalServerError) 71 | _, _ = w.Write([]byte(e.Error())) 72 | return 73 | } 74 | w.WriteHeader(http.StatusUnauthorized) 75 | } 76 | 77 | // create the authorization code callback and register it for use. 78 | authCodeCallback, err := AuthCode(ctx, p, &SingleRequestReader{Request: authCodeAttempt}, successFn, errorFn) 79 | if err != nil { 80 | // handle error 81 | } 82 | http.HandleFunc("/auth-code-callback", authCodeCallback) 83 | 84 | // create an implicit flow callback and register it for use. 85 | implicitCallback, err := Implicit(ctx, p, &SingleRequestReader{Request: implicitAttempt}, successFn, errorFn) 86 | if err != nil { 87 | // handle error 88 | } 89 | http.HandleFunc("/implicit-callback", implicitCallback) 90 | ``` 91 | -------------------------------------------------------------------------------- /oidc/callback/authcode.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package callback 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "net/http" 10 | 11 | "github.com/hashicorp/cap/oidc" 12 | ) 13 | 14 | // AuthCode creates an oidc authorization code callback handler which 15 | // uses a RequestReader to read existing oidc.Request(s) via the request's 16 | // oidc "state" parameter as a key for the lookup. In additional to the 17 | // typical authorization code flow, it also handles the authorization code flow 18 | // with PKCE. 19 | // 20 | // The SuccessResponseFunc is used to create a response when callback is 21 | // successful. 22 | // 23 | // The ErrorResponseFunc is to create a response when the callback fails. 24 | func AuthCode(ctx context.Context, p *oidc.Provider, rw RequestReader, sFn SuccessResponseFunc, eFn ErrorResponseFunc) (http.HandlerFunc, error) { 25 | const op = "callback.AuthCode" 26 | if p == nil { 27 | return nil, fmt.Errorf("%s: provider is empty: %w", op, oidc.ErrInvalidParameter) 28 | } 29 | if rw == nil { 30 | return nil, fmt.Errorf("%s: request reader is empty: %w", op, oidc.ErrInvalidParameter) 31 | } 32 | if sFn == nil { 33 | return nil, fmt.Errorf("%s: success response func is empty: %w", op, oidc.ErrInvalidParameter) 34 | } 35 | if eFn == nil { 36 | return nil, fmt.Errorf("%s: error response func is empty: %w", op, oidc.ErrInvalidParameter) 37 | } 38 | return func(w http.ResponseWriter, req *http.Request) { 39 | const op = "callback.AuthCode" 40 | 41 | reqState := req.FormValue("state") 42 | 43 | if err := req.FormValue("error"); err != "" { 44 | // get parameters from either the body or query parameters. 45 | // FormValue prioritizes body values, if found 46 | reqError := &AuthenErrorResponse{ 47 | Error: err, 48 | Description: req.FormValue("error_description"), 49 | Uri: req.FormValue("error_uri"), 50 | } 51 | eFn(reqState, reqError, nil, w, req) 52 | return 53 | } 54 | 55 | // get parameters from either the body or query parameters. 56 | // FormValue prioritizes body values, if found. 57 | reqCode := req.FormValue("code") 58 | 59 | oidcRequest, err := rw.Read(ctx, reqState) 60 | if err != nil { 61 | responseErr := fmt.Errorf("%s: unable to read auth code request: %w", op, err) 62 | eFn(reqState, nil, responseErr, w, req) 63 | return 64 | } 65 | if oidcRequest == nil { 66 | // could have expired or it could be invalid... no way to known for 67 | // sure 68 | responseErr := fmt.Errorf("%s: auth code request not found: %w", op, oidc.ErrNotFound) 69 | eFn(reqState, nil, responseErr, w, req) 70 | return 71 | } 72 | if oidcRequest.IsExpired() { 73 | responseErr := fmt.Errorf("%s: authentication request is expired: %w", op, oidc.ErrExpiredRequest) 74 | eFn(reqState, nil, responseErr, w, req) 75 | return 76 | } 77 | 78 | if reqState != oidcRequest.State() { 79 | // the stateReadWriter didn't return the correct state for the key 80 | // given... this is an internal sort of error on the part of the 81 | // reader. 82 | responseErr := fmt.Errorf("%s: authentication state and response state are not equal: %w", op, oidc.ErrInvalidResponseState) 83 | eFn(reqState, nil, responseErr, w, req) 84 | return 85 | } 86 | if useImplicit, _ := oidcRequest.ImplicitFlow(); useImplicit { 87 | responseErr := fmt.Errorf("%s: state (%s) should not be using the authorization code flow: %w", op, oidcRequest.State(), oidc.ErrInvalidFlow) 88 | eFn(reqState, nil, responseErr, w, req) 89 | return 90 | } 91 | 92 | responseToken, err := p.Exchange(ctx, oidcRequest, reqState, reqCode) 93 | if err != nil { 94 | responseErr := fmt.Errorf("%s: unable to exchange authorization code: %w", op, err) 95 | eFn(reqState, nil, responseErr, w, req) 96 | return 97 | } 98 | sFn(reqState, responseToken, w, req) 99 | }, nil 100 | } 101 | -------------------------------------------------------------------------------- /oidc/callback/authcode_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package callback 5 | 6 | import ( 7 | "context" 8 | "encoding/json" 9 | "io/ioutil" 10 | "net/http" 11 | "net/http/httptest" 12 | "testing" 13 | "time" 14 | 15 | "github.com/hashicorp/cap/oidc" 16 | "github.com/stretchr/testify/assert" 17 | "github.com/stretchr/testify/require" 18 | ) 19 | 20 | func TestAuthCode(t *testing.T) { 21 | ctx := context.Background() 22 | clientID := "test-client-id" 23 | clientSecret := "test-client-secret" 24 | tp := oidc.StartTestProvider(t) 25 | p := testNewProvider(t, clientID, clientSecret, "http://alice.com", tp) 26 | rw := &SingleRequestReader{} 27 | 28 | tests := []struct { 29 | name string 30 | p *oidc.Provider 31 | rw RequestReader 32 | sFn SuccessResponseFunc 33 | eFn ErrorResponseFunc 34 | wantErr bool 35 | wantIsErr error 36 | }{ 37 | {"valid", p, rw, testSuccessFn, testFailFn, false, nil}, 38 | {"nil-p", nil, rw, testSuccessFn, testFailFn, true, oidc.ErrInvalidParameter}, 39 | {"nil-rw", p, nil, testSuccessFn, testFailFn, true, oidc.ErrInvalidParameter}, 40 | {"nil-sFn", p, rw, nil, testFailFn, true, oidc.ErrInvalidParameter}, 41 | {"nil-eFn", p, rw, testSuccessFn, nil, true, oidc.ErrInvalidParameter}, 42 | } 43 | for _, tt := range tests { 44 | t.Run(tt.name, func(t *testing.T) { 45 | assert, require := assert.New(t), require.New(t) 46 | got, err := AuthCode(ctx, tt.p, tt.rw, tt.sFn, tt.eFn) 47 | if tt.wantErr { 48 | require.Error(err) 49 | return 50 | } 51 | require.NoError(err) 52 | assert.NotEmpty(got) 53 | }) 54 | } 55 | } 56 | 57 | func Test_AuthCodeResponses(t *testing.T) { 58 | ctx := context.Background() 59 | clientID := "test-client-id" 60 | clientSecret := "test-client-secret" 61 | tp := oidc.StartTestProvider(t) 62 | tp.SetExpectedAuthCode("valid-code") 63 | callbackSrv := httptest.NewTLSServer(nil) 64 | defer callbackSrv.Close() 65 | 66 | redirect := callbackSrv.URL 67 | tp.SetAllowedRedirectURIs([]string{redirect, redirect}) 68 | 69 | p := testNewProvider(t, clientID, clientSecret, redirect, tp) 70 | 71 | tests := []struct { 72 | name string 73 | exp time.Duration 74 | nonceOverride string 75 | stateOverride string 76 | readerOverride RequestReader 77 | disableExchange bool 78 | want http.HandlerFunc 79 | wantStatusCode int 80 | wantError bool 81 | wantRespError string 82 | wantRespDescription string 83 | }{ 84 | { 85 | name: "basic", 86 | exp: 1 * time.Minute, 87 | wantStatusCode: http.StatusOK, 88 | }, 89 | { 90 | name: "bad-nonce", 91 | exp: 1 * time.Minute, 92 | nonceOverride: "bad-nonce", 93 | wantStatusCode: http.StatusUnauthorized, 94 | wantError: true, 95 | wantRespError: "access_denied", 96 | }, 97 | { 98 | name: "expired", 99 | exp: 1 * time.Nanosecond, 100 | wantStatusCode: http.StatusInternalServerError, 101 | wantError: true, 102 | wantRespError: "internal-callback-error", 103 | wantRespDescription: "request is expired", 104 | }, 105 | { 106 | name: "state-not-matching", 107 | exp: 1 * time.Minute, 108 | stateOverride: "not-matching", 109 | wantStatusCode: http.StatusInternalServerError, 110 | wantError: true, 111 | wantRespError: "internal-callback-error", 112 | wantRespDescription: "not found", 113 | }, 114 | { 115 | name: "state-returns-nil", 116 | exp: 1 * time.Minute, 117 | readerOverride: &testNilRequestReader{}, 118 | wantStatusCode: http.StatusInternalServerError, 119 | wantError: true, 120 | wantRespError: "internal-callback-error", 121 | wantRespDescription: "not found", 122 | }, 123 | { 124 | name: "bad-exchange", 125 | exp: 1 * time.Minute, 126 | disableExchange: true, 127 | wantStatusCode: http.StatusInternalServerError, 128 | wantError: true, 129 | wantRespError: "internal-callback-error", 130 | wantRespDescription: "Unauthorized\nResponse", 131 | }, 132 | } 133 | for _, tt := range tests { 134 | t.Run(tt.name, func(t *testing.T) { 135 | assert, require := assert.New(t), require.New(t) 136 | oidcRequest, err := oidc.NewRequest(tt.exp, redirect) 137 | require.NoError(err) 138 | 139 | switch { 140 | case tt.nonceOverride != "": 141 | tp.SetExpectedAuthNonce(tt.nonceOverride) 142 | default: 143 | tp.SetExpectedAuthNonce(oidcRequest.Nonce()) 144 | } 145 | 146 | if tt.stateOverride != "" { 147 | tp.SetExpectedState(tt.stateOverride) 148 | defer tp.SetExpectedState("") 149 | } 150 | if tt.disableExchange { 151 | tp.SetDisableToken(true) 152 | defer tp.SetDisableToken(false) 153 | } 154 | var reader RequestReader 155 | switch { 156 | case tt.readerOverride != nil: 157 | reader = tt.readerOverride 158 | default: 159 | reader = &SingleRequestReader{oidcRequest} 160 | } 161 | callbackSrv.Config.Handler, err = AuthCode(ctx, p, reader, testSuccessFn, testFailFn) 162 | require.NoError(err) 163 | 164 | authURL, err := p.AuthURL(ctx, oidcRequest) 165 | require.NoError(err) 166 | 167 | resp, err := tp.HTTPClient().Get(authURL) 168 | require.NoError(err) 169 | contents, err := ioutil.ReadAll(resp.Body) 170 | require.NoError(err) 171 | defer resp.Body.Close() 172 | 173 | assert.Equal(tt.wantStatusCode, resp.StatusCode) 174 | 175 | if tt.wantError { 176 | var errResp AuthenErrorResponse 177 | require.NoError(json.Unmarshal(contents, &errResp)) 178 | assert.Equal(tt.wantRespError, errResp.Error) 179 | if tt.wantRespDescription != "" { 180 | assert.Contains(errResp.Description, tt.wantRespDescription) 181 | } 182 | return 183 | } 184 | assert.Equal("login successful", string(contents)) 185 | }) 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /oidc/callback/docs.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | /* 5 | callback is a package that provides callbacks (in the form of http.HandlerFunc) 6 | for handling OIDC provider responses to authorization code flow (with optional 7 | PKCE) and implicit flow authentication attempts. 8 | */ 9 | package callback 10 | -------------------------------------------------------------------------------- /oidc/callback/docs_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package callback 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "net/http" 10 | "time" 11 | 12 | "github.com/hashicorp/cap/oidc" 13 | ) 14 | 15 | func Example() { 16 | // Create a new Config 17 | pc, err := oidc.NewConfig( 18 | "http://your-issuer.com/", 19 | "your_client_id", 20 | "your_client_secret", 21 | []oidc.Alg{oidc.RS256}, 22 | []string{"http://your_redirect_url/auth-code-callback", "http://your_redirect_url/implicit-callback"}, 23 | ) 24 | if err != nil { 25 | // handle error 26 | } 27 | 28 | // Create a provider 29 | p, err := oidc.NewProvider(pc) 30 | if err != nil { 31 | // handle error 32 | } 33 | defer p.Done() 34 | 35 | // Create a Request for a user's authentication attempt that will use the 36 | // authorization code flow. (See NewRequest(...) using the WithPKCE and 37 | // WithImplicit options for creating a Request that uses those flows.) 38 | ttl := 2 * time.Minute 39 | authCodeAttempt, err := oidc.NewRequest(ttl, "http://your_redirect_url/auth-code-callback") 40 | if err != nil { 41 | // handle error 42 | } 43 | 44 | // Create a Request for a user's authentication attempt using an implicit 45 | // flow. 46 | implicitAttempt, err := oidc.NewRequest(ttl, "http://your_redirect_url/implicit-callback") 47 | if err != nil { 48 | // handle error 49 | } 50 | 51 | // Create an authorization code flow callback 52 | // A function to handle successful attempts. 53 | successFn := func( 54 | state string, 55 | t oidc.Token, 56 | w http.ResponseWriter, 57 | req *http.Request, 58 | ) { 59 | w.WriteHeader(http.StatusOK) 60 | printableToken := fmt.Sprintf("id_token: %s", string(t.IDToken())) 61 | _, _ = w.Write([]byte(printableToken)) 62 | } 63 | // A function to handle errors and failed attempts. 64 | errorFn := func( 65 | state string, 66 | r *AuthenErrorResponse, 67 | e error, 68 | w http.ResponseWriter, 69 | req *http.Request, 70 | ) { 71 | if e != nil { 72 | w.WriteHeader(http.StatusInternalServerError) 73 | _, _ = w.Write([]byte(e.Error())) 74 | return 75 | } 76 | w.WriteHeader(http.StatusUnauthorized) 77 | } 78 | // create the authorization code callback and register it for use. 79 | authCodeCallback, err := AuthCode(context.Background(), p, &SingleRequestReader{Request: authCodeAttempt}, successFn, errorFn) 80 | if err != nil { 81 | // handle error 82 | } 83 | http.HandleFunc("/auth-code-callback", authCodeCallback) 84 | 85 | // create an implicit flow callback and register it for use. 86 | implicitCallback, err := Implicit(context.Background(), p, &SingleRequestReader{Request: implicitAttempt}, successFn, errorFn) 87 | if err != nil { 88 | // handle error 89 | } 90 | http.HandleFunc("/implicit-callback", implicitCallback) 91 | } 92 | 93 | func ExampleAuthCode() { 94 | // Create a new Config 95 | pc, err := oidc.NewConfig( 96 | "http://your-issuer.com/", 97 | "your_client_id", 98 | "your_client_secret", 99 | []oidc.Alg{oidc.RS256}, 100 | []string{"http://your_redirect_url/auth-code-callback"}, 101 | ) 102 | if err != nil { 103 | // handle error 104 | } 105 | 106 | // Create a provider 107 | p, err := oidc.NewProvider(pc) 108 | if err != nil { 109 | // handle error 110 | } 111 | defer p.Done() 112 | 113 | // Create a Request for a user's authentication attempt that will use the 114 | // authorization code flow. (See NewRequest(...) using the WithPKCE and 115 | // WithImplicit options for creating a Request that uses those flows.) 116 | ttl := 2 * time.Minute 117 | authCodeAttempt, err := oidc.NewRequest(ttl, "http://your_redirect_url/auth-code-callback") 118 | if err != nil { 119 | // handle error 120 | } 121 | 122 | // Create an authorization code flow callback 123 | // A function to handle successful attempts. 124 | successFn := func( 125 | state string, 126 | t oidc.Token, 127 | w http.ResponseWriter, 128 | req *http.Request, 129 | ) { 130 | w.WriteHeader(http.StatusOK) 131 | printableToken := fmt.Sprintf("id_token: %s", string(t.IDToken())) 132 | _, _ = w.Write([]byte(printableToken)) 133 | } 134 | // A function to handle errors and failed attempts. 135 | errorFn := func( 136 | state string, 137 | r *AuthenErrorResponse, 138 | e error, 139 | w http.ResponseWriter, 140 | req *http.Request, 141 | ) { 142 | if e != nil { 143 | w.WriteHeader(http.StatusInternalServerError) 144 | _, _ = w.Write([]byte(e.Error())) 145 | return 146 | } 147 | w.WriteHeader(http.StatusUnauthorized) 148 | } 149 | // create the authorization code callback and register it for use. 150 | authCodeCallback, err := AuthCode(context.Background(), p, &SingleRequestReader{Request: authCodeAttempt}, successFn, errorFn) 151 | if err != nil { 152 | // handle error 153 | } 154 | http.HandleFunc("/auth-code-callback", authCodeCallback) 155 | } 156 | 157 | func ExampleImplicit() { 158 | // Create a new Config 159 | pc, err := oidc.NewConfig( 160 | "http://your-issuer.com/", 161 | "your_client_id", 162 | "your_client_secret", 163 | []oidc.Alg{oidc.RS256}, 164 | []string{"http://your_redirect_url/implicit-callback"}, 165 | ) 166 | if err != nil { 167 | // handle error 168 | } 169 | 170 | // Create a provider 171 | p, err := oidc.NewProvider(pc) 172 | if err != nil { 173 | // handle error 174 | } 175 | defer p.Done() 176 | 177 | // Create a Request for a user's authentication attempt using an implicit 178 | // flow. 179 | ttl := 2 * time.Minute 180 | implicitAttempt, err := oidc.NewRequest(ttl, "http://your_redirect_url/implicit-callback") 181 | if err != nil { 182 | // handle error 183 | } 184 | 185 | // Create an authorization code flow callback 186 | // A function to handle successful attempts. 187 | successFn := func( 188 | state string, 189 | t oidc.Token, 190 | w http.ResponseWriter, 191 | req *http.Request, 192 | ) { 193 | w.WriteHeader(http.StatusOK) 194 | printableToken := fmt.Sprintf("id_token: %s", string(t.IDToken())) 195 | _, _ = w.Write([]byte(printableToken)) 196 | } 197 | // A function to handle errors and failed attempts. 198 | errorFn := func( 199 | state string, 200 | r *AuthenErrorResponse, 201 | e error, 202 | w http.ResponseWriter, 203 | req *http.Request, 204 | ) { 205 | if e != nil { 206 | w.WriteHeader(http.StatusInternalServerError) 207 | _, _ = w.Write([]byte(e.Error())) 208 | return 209 | } 210 | w.WriteHeader(http.StatusUnauthorized) 211 | } 212 | 213 | // create an implicit flow callback and register it for use. 214 | implicitCallback, err := Implicit(context.Background(), p, &SingleRequestReader{Request: implicitAttempt}, successFn, errorFn) 215 | if err != nil { 216 | // handle error 217 | } 218 | http.HandleFunc("/implicit-callback", implicitCallback) 219 | } 220 | -------------------------------------------------------------------------------- /oidc/callback/implicit.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package callback 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "net/http" 10 | 11 | "github.com/hashicorp/cap/oidc" 12 | "golang.org/x/oauth2" 13 | ) 14 | 15 | // Implicit creates an oidc implicit flow callback handler which 16 | // uses a RequestReader to read existing oidc.Request(s) via the request's 17 | // oidc "state" parameter as a key for the lookup. 18 | // 19 | // It should be noted that if your OIDC provider supports PKCE, then 20 | // use it over the implicit flow 21 | // 22 | // The SuccessResponseFunc is used to create a response when callback is 23 | // successful. 24 | // 25 | // The ErrorResponseFunc is to create a response when the callback fails. 26 | func Implicit(ctx context.Context, p *oidc.Provider, rw RequestReader, sFn SuccessResponseFunc, eFn ErrorResponseFunc) (http.HandlerFunc, error) { 27 | const op = "callback.Implicit" 28 | if p == nil { 29 | return nil, fmt.Errorf("%s: provider is empty: %w", op, oidc.ErrInvalidParameter) 30 | } 31 | if rw == nil { 32 | return nil, fmt.Errorf("%s: request reader is empty: %w", op, oidc.ErrInvalidParameter) 33 | } 34 | if sFn == nil { 35 | return nil, fmt.Errorf("%s: success response func is empty: %w", op, oidc.ErrInvalidParameter) 36 | } 37 | if eFn == nil { 38 | return nil, fmt.Errorf("%s: error response func is empty: %w", op, oidc.ErrInvalidParameter) 39 | } 40 | return func(w http.ResponseWriter, req *http.Request) { 41 | const op = "callback.Implicit" 42 | 43 | reqState := req.FormValue("state") 44 | 45 | if err := req.FormValue("error"); err != "" { 46 | // get parameters from either the body or query parameters. 47 | // FormValue prioritizes body values, if found 48 | reqError := &AuthenErrorResponse{ 49 | Error: err, 50 | Description: req.FormValue("error_description"), 51 | Uri: req.FormValue("error_uri"), 52 | } 53 | eFn(reqState, reqError, nil, w, req) 54 | return 55 | } 56 | if reqState == "" { 57 | responseErr := fmt.Errorf("%s: empty state parameter: %w", op, oidc.ErrInvalidParameter) 58 | eFn(reqState, nil, responseErr, w, req) 59 | return 60 | } 61 | 62 | oidcRequest, err := rw.Read(ctx, reqState) 63 | if err != nil { 64 | responseErr := fmt.Errorf("%s: unable to read auth code request: %w", op, err) 65 | eFn(reqState, nil, responseErr, w, req) 66 | return 67 | } 68 | if oidcRequest == nil { 69 | // could have expired or it could be invalid... no way to known for 70 | // sure 71 | responseErr := fmt.Errorf("%s: auth code request not found: %w", op, oidc.ErrNotFound) 72 | eFn(reqState, nil, responseErr, w, req) 73 | return 74 | } 75 | useImplicit, includeAccessToken := oidcRequest.ImplicitFlow() 76 | if !useImplicit { 77 | responseErr := fmt.Errorf("%s: request (%s) should not be using the implicit flow: %w", op, oidcRequest.State(), oidc.ErrInvalidFlow) 78 | eFn(reqState, nil, responseErr, w, req) 79 | return 80 | } 81 | 82 | if oidcRequest.IsExpired() { 83 | responseErr := fmt.Errorf("%s: authentication request is expired: %w", op, oidc.ErrExpiredRequest) 84 | eFn(reqState, nil, responseErr, w, req) 85 | return 86 | } 87 | 88 | if reqState != oidcRequest.State() { 89 | // the stateReadWriter didn't return the correct state for the key 90 | // given... this is an internal sort of error on the part of the 91 | // reader. 92 | responseErr := fmt.Errorf("%s: authen state (%s) and response state (%s) are not equal: %w", op, oidcRequest.State(), reqState, oidc.ErrInvalidResponseState) 93 | eFn(reqState, nil, responseErr, w, req) 94 | return 95 | } 96 | 97 | reqIDToken := oidc.IDToken(req.FormValue("id_token")) 98 | if _, err := p.VerifyIDToken(ctx, reqIDToken, oidcRequest); err != nil { 99 | responseErr := fmt.Errorf("%s: unable to verify id_token: %w", op, err) 100 | eFn(reqState, nil, responseErr, w, req) 101 | return 102 | } 103 | 104 | var oath2Token *oauth2.Token 105 | if includeAccessToken { 106 | reqAccessToken := req.FormValue("access_token") 107 | if reqAccessToken != "" { 108 | if _, err := reqIDToken.VerifyAccessToken(oidc.AccessToken(reqAccessToken)); err != nil { 109 | responseErr := fmt.Errorf("%s: unable to verify access_token: %w", op, err) 110 | eFn(reqState, nil, responseErr, w, req) 111 | return 112 | } 113 | oath2Token = &oauth2.Token{ 114 | AccessToken: reqAccessToken, 115 | } 116 | } 117 | } 118 | 119 | responseToken, err := oidc.NewToken(oidc.IDToken(reqIDToken), oath2Token) 120 | if err != nil { 121 | responseErr := fmt.Errorf("%s: unable to create response tokens: %w", op, err) 122 | eFn(reqState, nil, responseErr, w, req) 123 | return 124 | } 125 | sFn(reqState, responseToken, w, req) 126 | }, nil 127 | } 128 | -------------------------------------------------------------------------------- /oidc/callback/request_reader.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package callback 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/hashicorp/cap/oidc" 10 | ) 11 | 12 | // RequestReader defines an interface for finding and reading an oidc.Request 13 | // 14 | // Implementations must be concurrently safe, since the reader will likely be 15 | // used within a concurrent http.Handler 16 | type RequestReader interface { 17 | // Read an existing Request entry. The returned request's State() 18 | // must match the state used to look it up. Implementations must be 19 | // concurrently safe, which likely means returning a deep copy. 20 | Read(ctx context.Context, state string) (oidc.Request, error) 21 | } 22 | 23 | // SingleRequestReader implements the RequestReader interface for a single request. 24 | // It is concurrently safe. 25 | type SingleRequestReader struct { 26 | Request oidc.Request 27 | } 28 | 29 | // Read() will return it's single-request if the state matches it's Request.State(), 30 | // otherwise it returns an error of oidc.ErrNotFound. It satisfies the 31 | // RequestReader interface. Read() is concurrently safe. 32 | func (sr *SingleRequestReader) Read(ctx context.Context, state string) (oidc.Request, error) { 33 | if sr.Request.State() != state { 34 | return nil, oidc.ErrNotFound 35 | } 36 | return sr.Request, nil 37 | } 38 | -------------------------------------------------------------------------------- /oidc/callback/request_reader_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package callback 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "testing" 10 | "time" 11 | 12 | "github.com/hashicorp/cap/oidc" 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | ) 16 | 17 | type testRequest struct { 18 | *oidc.Req 19 | } 20 | 21 | func newTestRequest() *testRequest { 22 | r, _ := oidc.NewRequest(1*time.Minute, "http://whatever.com") 23 | return &testRequest{r} 24 | } 25 | 26 | func TestSingleRequestReader_Read(t *testing.T) { 27 | ctx := context.Background() 28 | tests := []struct { 29 | name string 30 | oidcRequest oidc.Request 31 | idOverride string 32 | wantErr bool 33 | }{ 34 | {"valid", newTestRequest(), "", false}, 35 | {"not-found", newTestRequest(), "not-found", true}, 36 | } 37 | for _, tt := range tests { 38 | t.Run(tt.name, func(t *testing.T) { 39 | assert, require := assert.New(t), require.New(t) 40 | sr := &SingleRequestReader{ 41 | Request: tt.oidcRequest, 42 | } 43 | var state string 44 | switch { 45 | case tt.idOverride != "": 46 | state = tt.idOverride 47 | default: 48 | state = sr.Request.State() 49 | } 50 | got, err := sr.Read(ctx, state) 51 | if tt.wantErr { 52 | require.Error(err) 53 | assert.True(errors.Is(err, oidc.ErrNotFound)) 54 | return 55 | } 56 | require.NoError(err) 57 | assert.Equal(tt.oidcRequest, got) 58 | }) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /oidc/callback/response_func.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package callback 5 | 6 | import ( 7 | "net/http" 8 | 9 | "github.com/hashicorp/cap/oidc" 10 | ) 11 | 12 | // SuccessResponseFunc is used by Callbacks to create a http response when the 13 | // callback is successful. 14 | // 15 | // The function state parameter will contain the state that was returned as 16 | // part of a successful oidc authentication response. The oidc.Token is the 17 | // result of a successful token exchange with the provider. The function 18 | // should use the http.ResponseWriter to send back whatever content (headers, 19 | // html, JSON, etc) it wishes to the client that originated the oidc flow. 20 | // 21 | // Just a reminder that the function parameters could also be used to 22 | // update the oidc.Request for the request or log info about the request, if the 23 | // implementation requires it. 24 | type SuccessResponseFunc func(state string, t oidc.Token, w http.ResponseWriter, req *http.Request) 25 | 26 | // ErrorResponseFunc is used by Callbacks to create a http response when the 27 | // callback fails. 28 | // 29 | // The function receives the state returned as part of the oidc authentication 30 | // response. It also gets parameters for the oidc authentication error response 31 | // and/or the callback error raised while processing the request. The function 32 | // should use the http.ResponseWriter to send back whatever content (headers, 33 | // html, JSON, etc) it wishes to the client that originated the oidc flow. 34 | // 35 | // Just a reminder that the function parameters could also be used to 36 | // update the oidc.Request for the request or log info about the request, if the 37 | // implementation requires it. 38 | type ErrorResponseFunc func(state string, respErr *AuthenErrorResponse, e error, w http.ResponseWriter, req *http.Request) 39 | 40 | // AuthenErrorResponse represents Oauth2 error responses. See: 41 | // https://openid.net/specs/openid-connect-core-1_0.html#AuthError 42 | type AuthenErrorResponse struct { 43 | Error string 44 | Description string 45 | Uri string 46 | } 47 | -------------------------------------------------------------------------------- /oidc/callback/testing.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package callback 5 | 6 | import ( 7 | "context" 8 | "encoding/json" 9 | "net/http" 10 | "testing" 11 | 12 | "github.com/hashicorp/cap/oidc" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | // testSuccessFn is a test SuccessResponseFunc 17 | func testSuccessFn(state string, t oidc.Token, w http.ResponseWriter, req *http.Request) { 18 | w.WriteHeader(http.StatusOK) 19 | _, _ = w.Write([]byte("login successful")) 20 | } 21 | 22 | // testFailFn is a test ErrorResponseFunc 23 | func testFailFn(state string, r *AuthenErrorResponse, e error, w http.ResponseWriter, req *http.Request) { 24 | if e != nil { 25 | w.WriteHeader(http.StatusInternalServerError) 26 | j, _ := json.Marshal(&AuthenErrorResponse{ 27 | Error: "internal-callback-error", 28 | Description: e.Error(), 29 | }) 30 | _, _ = w.Write(j) 31 | return 32 | } 33 | if r != nil { 34 | w.WriteHeader(http.StatusUnauthorized) 35 | j, _ := json.Marshal(r) 36 | _, _ = w.Write(j) 37 | return 38 | } 39 | w.WriteHeader(http.StatusInternalServerError) 40 | j, _ := json.Marshal(&AuthenErrorResponse{ 41 | Error: "unknown-callback-error", 42 | }) 43 | _, _ = w.Write(j) 44 | } 45 | 46 | // testNewProvider creates a new Provider. It uses the TestProvider (tp) to properly 47 | // construct the provider's configuration (see testNewConfig). This is helpful internally, but 48 | // intentionally not exported. 49 | func testNewProvider(t *testing.T, clientID, clientSecret, redirectURL string, tp *oidc.TestProvider) *oidc.Provider { 50 | const op = "testNewProvider" 51 | t.Helper() 52 | require := require.New(t) 53 | require.NotEmptyf(clientID, "%s: client id is empty", op) 54 | require.NotEmptyf(clientSecret, "%s: client secret is empty", op) 55 | require.NotEmptyf(redirectURL, "%s: redirect URL is empty", op) 56 | 57 | tc := testNewConfig(t, clientID, clientSecret, redirectURL, tp) 58 | p, err := oidc.NewProvider(tc) 59 | require.NoError(err) 60 | t.Cleanup(p.Done) 61 | return p 62 | } 63 | 64 | // testNewConfig creates a new config from the TestProvider. It will set the 65 | // TestProvider's client ID/secret and use the TestProviders signing algorithm 66 | // when building the configuration. This is helpful internally, but 67 | // intentionally not exported. 68 | func testNewConfig(t *testing.T, clientID, clientSecret, allowedRedirectURL string, tp *oidc.TestProvider) *oidc.Config { 69 | const op = "testNewConfig" 70 | t.Helper() 71 | require := require.New(t) 72 | 73 | require.NotEmptyf(clientID, "%s: client id is empty", op) 74 | require.NotEmptyf(clientSecret, "%s: client secret is empty", op) 75 | require.NotEmptyf(allowedRedirectURL, "%s: redirect URL is empty", op) 76 | 77 | tp.SetClientCreds(clientID, clientSecret) 78 | _, _, alg, _ := tp.SigningKeys() 79 | c, err := oidc.NewConfig( 80 | tp.Addr(), 81 | clientID, 82 | oidc.ClientSecret(clientSecret), 83 | []oidc.Alg{alg}, 84 | []string{allowedRedirectURL}, 85 | nil, 86 | oidc.WithProviderCA(tp.CACert()), 87 | ) 88 | require.NoError(err) 89 | return c 90 | } 91 | 92 | type testNilRequestReader struct{} 93 | 94 | func (s *testNilRequestReader) Read(ctx context.Context, state string) (oidc.Request, error) { 95 | return nil, nil 96 | } 97 | -------------------------------------------------------------------------------- /oidc/clientassertion/algorithms.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package clientassertion 5 | 6 | import ( 7 | "crypto/rsa" 8 | "fmt" 9 | ) 10 | 11 | type ( 12 | // HSAlgorithm is an HMAC signature algorithm 13 | HSAlgorithm string 14 | // RSAlgorithm is an RSA signature algorithm 15 | RSAlgorithm string 16 | ) 17 | 18 | // JOSE asymmetric signing algorithm values as defined by RFC 7518. 19 | // See: https://tools.ietf.org/html/rfc7518#section-3.1 20 | const ( 21 | HS256 HSAlgorithm = "HS256" // HMAC using SHA-256 22 | HS384 HSAlgorithm = "HS384" // HMAC using SHA-384 23 | HS512 HSAlgorithm = "HS512" // HMAC using SHA-512 24 | RS256 RSAlgorithm = "RS256" // RSASSA-PKCS-v1.5 using SHA-256 25 | RS384 RSAlgorithm = "RS384" // RSASSA-PKCS-v1.5 using SHA-384 26 | RS512 RSAlgorithm = "RS512" // RSASSA-PKCS-v1.5 using SHA-512 27 | ) 28 | 29 | // Validate checks that the secret is a supported algorithm and that it's 30 | // the proper length for the HSAlgorithm: 31 | // - HS256: >= 32 bytes 32 | // - HS384: >= 48 bytes 33 | // - HS512: >= 64 bytes 34 | func (a HSAlgorithm) Validate(secret string) error { 35 | const op = "HSAlgorithm.Validate" 36 | if secret == "" { 37 | return fmt.Errorf("%s: %w: empty", op, ErrInvalidSecretLength) 38 | } 39 | // rfc7518 https://datatracker.ietf.org/doc/html/rfc7518#section-3.2 40 | // states: 41 | // A key of the same size as the hash output (for instance, 256 bits 42 | // for "HS256") or larger MUST be used 43 | // e.g. 256 / 8 = 32 bytes 44 | var minLen int 45 | switch a { 46 | case HS256: 47 | minLen = 32 48 | case HS384: 49 | minLen = 48 50 | case HS512: 51 | minLen = 64 52 | default: 53 | return fmt.Errorf("%s: %w %q for client secret", op, ErrUnsupportedAlgorithm, a) 54 | } 55 | if len(secret) < minLen { 56 | return fmt.Errorf("%s: %w: %q must be at least %d bytes long", op, ErrInvalidSecretLength, a, minLen) 57 | } 58 | return nil 59 | } 60 | 61 | // Validate checks that the key is a supported algorithm and is valid per 62 | // rsa.PrivateKey's Validate() method. 63 | func (a RSAlgorithm) Validate(key *rsa.PrivateKey) error { 64 | const op = "RSAlgorithm.Validate" 65 | if key == nil { 66 | return fmt.Errorf("%s: %w", op, ErrNilPrivateKey) 67 | } 68 | switch a { 69 | case RS256, RS384, RS512: 70 | if err := key.Validate(); err != nil { 71 | return fmt.Errorf("%s: %w", op, err) 72 | } 73 | return nil 74 | default: 75 | return fmt.Errorf("%s: %w %q for for RSA key", op, ErrUnsupportedAlgorithm, a) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /oidc/clientassertion/client_assertion.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | // Package clientassertion signs JWTs with a Private Key or Client Secret 5 | // for use in OIDC client_assertion requests, A.K.A. private_key_jwt. 6 | // reference: https://oauth.net/private-key-jwt/ 7 | package clientassertion 8 | 9 | import ( 10 | "crypto/rsa" 11 | "errors" 12 | "fmt" 13 | "time" 14 | 15 | "github.com/go-jose/go-jose/v4" 16 | "github.com/go-jose/go-jose/v4/jwt" 17 | "github.com/hashicorp/go-uuid" 18 | ) 19 | 20 | const ( 21 | // JWTTypeParam is the proper value for client_assertion_type. 22 | // https://www.rfc-editor.org/rfc/rfc7523.html#section-2.2 23 | JWTTypeParam = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" 24 | ) 25 | 26 | // NewJWTWithRSAKey creates a new JWT which will be signed with a private key. 27 | // 28 | // alg must be one of: 29 | // * RS256 30 | // * RS384 31 | // * RS512 32 | // 33 | // Supported Options: 34 | // * WithKeyID 35 | // * WithHeaders 36 | func NewJWTWithRSAKey(clientID string, audience []string, 37 | alg RSAlgorithm, key *rsa.PrivateKey, opts ...Option, 38 | ) (*JWT, error) { 39 | const op = "clientassertion.NewJWTWithRSAKey" 40 | 41 | j := &JWT{ 42 | clientID: clientID, 43 | audience: audience, 44 | alg: jose.SignatureAlgorithm(alg), 45 | key: key, 46 | headers: make(map[string]string), 47 | genID: uuid.GenerateUUID, 48 | now: time.Now, 49 | } 50 | 51 | var errs []error 52 | if clientID == "" { 53 | errs = append(errs, ErrMissingClientID) 54 | } 55 | if len(audience) == 0 { 56 | errs = append(errs, ErrMissingAudience) 57 | } 58 | if alg == "" { 59 | errs = append(errs, ErrMissingAlgorithm) 60 | } 61 | 62 | // rsa-specific 63 | if key == nil { 64 | errs = append(errs, ErrMissingKey) 65 | } else { 66 | if err := alg.Validate(key); err != nil { 67 | errs = append(errs, err) 68 | } 69 | } 70 | 71 | for _, opt := range opts { 72 | if err := opt(j); err != nil { 73 | errs = append(errs, err) 74 | } 75 | } 76 | if len(errs) > 0 { 77 | return nil, fmt.Errorf("%s: %w", op, errors.Join(errs...)) 78 | } 79 | 80 | return j, nil 81 | } 82 | 83 | // NewJWTWithHMAC creates a new JWT which will be signed with an HMAC secret. 84 | // 85 | // alg must be one of: 86 | // * HS256 with a >= 32 byte secret 87 | // * HS384 with a >= 48 byte secret 88 | // * HS512 with a >= 64 byte secret 89 | // 90 | // Supported Options: 91 | // * WithKeyID 92 | // * WithHeaders 93 | func NewJWTWithHMAC(clientID string, audience []string, 94 | alg HSAlgorithm, secret string, opts ...Option, 95 | ) (*JWT, error) { 96 | const op = "clientassertion.NewJWTWithHMAC" 97 | j := &JWT{ 98 | clientID: clientID, 99 | audience: audience, 100 | alg: jose.SignatureAlgorithm(alg), 101 | secret: secret, 102 | headers: make(map[string]string), 103 | genID: uuid.GenerateUUID, 104 | now: time.Now, 105 | } 106 | 107 | var errs []error 108 | if clientID == "" { 109 | errs = append(errs, ErrMissingClientID) 110 | } 111 | if len(audience) == 0 { 112 | errs = append(errs, ErrMissingAudience) 113 | } 114 | if alg == "" { 115 | errs = append(errs, ErrMissingAlgorithm) 116 | } 117 | 118 | // hmac-specific 119 | if secret == "" { 120 | errs = append(errs, ErrMissingSecret) 121 | } else { 122 | if err := alg.Validate(secret); err != nil { 123 | errs = append(errs, err) 124 | } 125 | } 126 | 127 | for _, opt := range opts { 128 | if err := opt(j); err != nil { 129 | errs = append(errs, err) 130 | } 131 | } 132 | if len(errs) > 0 { 133 | return nil, fmt.Errorf("%s: %w", op, errors.Join(errs...)) 134 | } 135 | 136 | return j, nil 137 | } 138 | 139 | // JWT is used to create a client assertion JWT, a special JWT used by an OAuth 140 | // 2.0 or OIDC client to authenticate themselves to an authorization server 141 | type JWT struct { 142 | // for JWT claims 143 | clientID string 144 | audience []string 145 | headers map[string]string 146 | 147 | // for signer 148 | alg jose.SignatureAlgorithm 149 | // key may be any type that jose.SigningKey accepts for its Key, 150 | // but today we only support RSA keys. 151 | key *rsa.PrivateKey 152 | // secret may be used instead of key 153 | secret string 154 | 155 | // these are overwritten for testing 156 | genID func() (string, error) 157 | now func() time.Time 158 | } 159 | 160 | // Serialize returns client assertion JWT which can be used by an OAuth 2.0 or 161 | // OIDC client to authenticate themselves to an authorization server 162 | func (j *JWT) Serialize() (string, error) { 163 | const op = "JWT.Serialize" 164 | signer, err := j.signer() 165 | if err != nil { 166 | return "", fmt.Errorf("%s: %w", op, err) 167 | } 168 | id, err := j.genID() 169 | if err != nil { 170 | return "", fmt.Errorf("%s: failed to generate token id: %w", op, err) 171 | } 172 | now := j.now().UTC() 173 | claims := &jwt.Claims{ 174 | Issuer: j.clientID, 175 | Subject: j.clientID, 176 | Audience: j.audience, 177 | Expiry: jwt.NewNumericDate(now.Add(5 * time.Minute)), 178 | NotBefore: jwt.NewNumericDate(now.Add(-1 * time.Second)), 179 | IssuedAt: jwt.NewNumericDate(now), 180 | ID: id, 181 | } 182 | builder := jwt.Signed(signer).Claims(claims) 183 | token, err := builder.Serialize() 184 | if err != nil { 185 | return "", fmt.Errorf("%s: failed to serialize token: %w", op, err) 186 | } 187 | return token, nil 188 | } 189 | 190 | func (j *JWT) signer() (jose.Signer, error) { 191 | const op = "signer" 192 | sKey := jose.SigningKey{ 193 | Algorithm: j.alg, 194 | } 195 | 196 | // the different New* constructors ensure these are mutually exclusive. 197 | if j.secret != "" { 198 | sKey.Key = []byte(j.secret) 199 | } 200 | if j.key != nil { 201 | sKey.Key = j.key 202 | } 203 | 204 | sOpts := &jose.SignerOptions{ 205 | ExtraHeaders: make(map[jose.HeaderKey]any, len(j.headers)), 206 | } 207 | for k, v := range j.headers { 208 | sOpts.ExtraHeaders[jose.HeaderKey(k)] = v 209 | } 210 | 211 | signer, err := jose.NewSigner(sKey, sOpts.WithType("JWT")) 212 | if err != nil { 213 | return nil, fmt.Errorf("%s: %w: %w", op, ErrCreatingSigner, err) 214 | } 215 | return signer, nil 216 | } 217 | 218 | // serializer is the primary interface implemented by JWT. 219 | type serializer interface { 220 | Serialize() (string, error) 221 | } 222 | 223 | // ensure JWT implements Serializer, which is accepted by the oidc option 224 | // oidc.WithClientAssertionJWT. 225 | var _ serializer = &JWT{} 226 | -------------------------------------------------------------------------------- /oidc/clientassertion/error.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package clientassertion 5 | 6 | import "errors" 7 | 8 | var ( 9 | // these may happen due to user error 10 | 11 | ErrMissingClientID = errors.New("missing client ID") 12 | ErrMissingAudience = errors.New("missing audience") 13 | ErrMissingAlgorithm = errors.New("missing signing algorithm") 14 | ErrMissingKeyID = errors.New("missing key ID") 15 | ErrMissingKey = errors.New("missing private key") 16 | ErrMissingSecret = errors.New("missing client secret") 17 | ErrKidHeader = errors.New(`"kid" not allowed in WithHeaders; use WithKeyID instead`) 18 | ErrCreatingSigner = errors.New("error creating jwt signer") 19 | 20 | // algorithm errors 21 | 22 | ErrUnsupportedAlgorithm = errors.New("unsupported algorithm") 23 | ErrInvalidSecretLength = errors.New("invalid secret length for algorithm") 24 | ErrNilPrivateKey = errors.New("nil private key") 25 | ) 26 | -------------------------------------------------------------------------------- /oidc/clientassertion/example_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package clientassertion 5 | 6 | import ( 7 | "crypto/rand" 8 | "crypto/rsa" 9 | "fmt" 10 | "log" 11 | 12 | "github.com/go-jose/go-jose/v4" 13 | "github.com/go-jose/go-jose/v4/jwt" 14 | ) 15 | 16 | func ExampleJWT() { 17 | cid := "client-id" 18 | aud := []string{"audience"} 19 | 20 | // With an HMAC client secret 21 | secret := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" // 32 bytes for HS256 22 | j, err := NewJWTWithHMAC(cid, aud, HS256, secret) 23 | if err != nil { 24 | log.Fatal(err) 25 | } 26 | signed, err := j.Serialize() 27 | if err != nil { 28 | log.Fatal(err) 29 | } 30 | 31 | { 32 | // decode and inspect the JWT -- this is the IDP's job, 33 | // but it illustrates the example. 34 | token, err := jwt.ParseSigned(signed, []jose.SignatureAlgorithm{"HS256"}) 35 | if err != nil { 36 | log.Fatal(err) 37 | } 38 | headers := token.Headers[0] 39 | fmt.Printf("ClientSecret\n Headers - Algorithm: %s; typ: %s\n", 40 | headers.Algorithm, headers.ExtraHeaders["typ"]) 41 | var claim jwt.Claims 42 | err = token.Claims([]byte(secret), &claim) 43 | if err != nil { 44 | log.Fatal(err) 45 | } 46 | fmt.Printf(" Claims - Issuer: %s; Subject: %s; Audience: %v\n", 47 | claim.Issuer, claim.Subject, claim.Audience) 48 | } 49 | 50 | // With an RSA key 51 | privKey, err := rsa.GenerateKey(rand.Reader, 2048) 52 | if err != nil { 53 | log.Fatal(err) 54 | } 55 | pubKey, ok := privKey.Public().(*rsa.PublicKey) 56 | if !ok { 57 | log.Fatal("couldn't get rsa.PublicKey from PrivateKey") 58 | } 59 | j, err = NewJWTWithRSAKey(cid, aud, RS256, privKey, 60 | // note: for some providers, they key ID may be an x5t derivation 61 | // of a cert generated from the private key. 62 | // if your key has an associated JWKS endpoint, it will be the "kid" 63 | // for the public key at /.well-known/jwks.json 64 | WithKeyID("some-key-id"), 65 | // extra headers, like x5t, are optional 66 | WithHeaders(map[string]string{ 67 | "x5t": "should-be-derived-from-a-cert", 68 | }), 69 | ) 70 | if err != nil { 71 | log.Fatal(err) 72 | } 73 | signed, err = j.Serialize() 74 | if err != nil { 75 | log.Fatal(err) 76 | } 77 | 78 | { // decode and inspect the JWT -- this is the IDP's job 79 | token, err := jwt.ParseSigned(signed, []jose.SignatureAlgorithm{"RS256"}) 80 | if err != nil { 81 | log.Fatal(err) 82 | } 83 | h := token.Headers[0] 84 | fmt.Printf("PrivateKey\n Headers - KeyID: %s; Algorithm: %s; typ: %s; x5t: %s\n", 85 | h.KeyID, h.Algorithm, h.ExtraHeaders["typ"], h.ExtraHeaders["x5t"]) 86 | var claim jwt.Claims 87 | err = token.Claims(pubKey, &claim) 88 | if err != nil { 89 | log.Fatal(err) 90 | } 91 | fmt.Printf(" Claims - Issuer: %s; Subject: %s; Audience: %v\n", 92 | claim.Issuer, claim.Subject, claim.Audience) 93 | } 94 | 95 | // Output: 96 | // ClientSecret 97 | // Headers - Algorithm: HS256; typ: JWT 98 | // Claims - Issuer: client-id; Subject: client-id; Audience: [audience] 99 | // PrivateKey 100 | // Headers - KeyID: some-key-id; Algorithm: RS256; typ: JWT; x5t: should-be-derived-from-a-cert 101 | // Claims - Issuer: client-id; Subject: client-id; Audience: [audience] 102 | } 103 | -------------------------------------------------------------------------------- /oidc/clientassertion/options.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package clientassertion 5 | 6 | import ( 7 | "fmt" 8 | ) 9 | 10 | // KeyIDHeader is the "kid" header on a JWT, which providers use to look up 11 | // the right public key to verify the JWT. 12 | const KeyIDHeader = "kid" 13 | 14 | // Option configures the JWT 15 | type Option func(*JWT) error 16 | 17 | // WithKeyID sets the "kid" header that OIDC providers use to look up the 18 | // public key to check the signed JWT 19 | func WithKeyID(keyID string) Option { 20 | const op = "WithKeyID" 21 | return func(j *JWT) error { 22 | if keyID == "" { 23 | return fmt.Errorf("%s: %w", op, ErrMissingKeyID) 24 | } 25 | j.headers[KeyIDHeader] = keyID 26 | return nil 27 | } 28 | } 29 | 30 | // WithHeaders sets extra JWT headers. 31 | // Do not set a "kid" header here; instead use WithKeyID. 32 | func WithHeaders(h map[string]string) Option { 33 | const op = "WithHeaders" 34 | return func(j *JWT) error { 35 | for k, v := range h { 36 | if k == KeyIDHeader { 37 | return fmt.Errorf("%s: %w", op, ErrKidHeader) 38 | } 39 | j.headers[k] = v 40 | } 41 | return nil 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /oidc/display.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package oidc 5 | 6 | // Display is a string value that specifies how the Authorization Server 7 | // displays the authentication and consent user interface pages to the End-User. 8 | // 9 | // See: https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest 10 | type Display string 11 | 12 | const ( 13 | // Defined the Display values that specifies how the Authorization Server 14 | // displays the authentication and consent user interface pages to the End-User. 15 | // 16 | // See: https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest 17 | Page Display = "page" 18 | Popup Display = "popup" 19 | Touch Display = "touch" 20 | WAP Display = "wap" 21 | ) 22 | -------------------------------------------------------------------------------- /oidc/docs.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | /* 5 | oidc is a package for writing clients that integrate with OIDC Providers using 6 | OIDC flows. 7 | 8 | Primary types provided by the package: 9 | 10 | * Request: represents one OIDC authentication flow for a user. It contains the 11 | data needed to uniquely represent that one-time flow across the multiple 12 | interactions needed to complete the OIDC flow the user is attempting. All 13 | Requests contain an expiration for the user's OIDC flow. Optionally, Requests may 14 | contain overrides of configured provider defaults for audiences, scopes and a 15 | redirect URL. 16 | 17 | * Token: represents an OIDC id_token, as well as an Oauth2 access_token and 18 | refresh_token (including the access_token expiry) 19 | 20 | * Config: provides the configuration for OIDC provider used by a relying 21 | party (for example: client ID/Secret, redirectURL, supported 22 | signing algorithms, additional scopes requested, etc) 23 | 24 | * Provider: provides integration with a provider. The provider provides 25 | capabilities like: generating an auth URL, exchanging codes for tokens, 26 | verifying tokens, making user info requests, etc. 27 | 28 | # The oidc.callback package 29 | 30 | The callback package includes handlers (http.HandlerFunc) which can be used 31 | for the callback leg an OIDC flow. Callback handlers for both the authorization 32 | code flow (with optional PKCE) and the implicit flow are provided. 33 | 34 | # Example apps 35 | 36 | Complete concise example solutions: 37 | 38 | * OIDC authentication CLI: 39 | https://github.com/hashicorp/cap/tree/main/oidc/examples/cli/ 40 | 41 | * OIDC authentication SPA: 42 | https://github.com/hashicorp/cap/tree/main/oidc/examples/spa/ 43 | */ 44 | package oidc 45 | -------------------------------------------------------------------------------- /oidc/error.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package oidc 5 | 6 | import ( 7 | "errors" 8 | ) 9 | 10 | var ( 11 | ErrInvalidParameter = errors.New("invalid parameter") 12 | ErrNilParameter = errors.New("nil parameter") 13 | ErrInvalidCACert = errors.New("invalid CA certificate") 14 | ErrInvalidIssuer = errors.New("invalid issuer") 15 | ErrExpiredRequest = errors.New("request is expired") 16 | ErrInvalidResponseState = errors.New("invalid response state") 17 | ErrInvalidSignature = errors.New("invalid signature") 18 | ErrInvalidSubject = errors.New("invalid subject") 19 | ErrInvalidAudience = errors.New("invalid audience") 20 | ErrInvalidNonce = errors.New("invalid nonce") 21 | ErrInvalidNotBefore = errors.New("invalid not before") 22 | ErrExpiredToken = errors.New("token is expired") 23 | ErrInvalidJWKs = errors.New("invalid jwks") 24 | ErrInvalidIssuedAt = errors.New("invalid issued at (iat)") 25 | ErrInvalidAuthorizedParty = errors.New("invalid authorized party (azp)") 26 | ErrInvalidAtHash = errors.New("access_token hash does not match value in id_token") 27 | ErrInvalidCodeHash = errors.New("authorization code hash does not match value in id_token") 28 | ErrTokenNotSigned = errors.New("token is not signed") 29 | ErrMalformedToken = errors.New("token malformed") 30 | ErrUnsupportedAlg = errors.New("unsupported signing algorithm") 31 | ErrIDGeneratorFailed = errors.New("id generation failed") 32 | ErrMissingIDToken = errors.New("id_token is missing") 33 | ErrMissingAccessToken = errors.New("access_token is missing") 34 | ErrIDTokenVerificationFailed = errors.New("id_token verification failed") 35 | ErrNotFound = errors.New("not found") 36 | ErrLoginFailed = errors.New("login failed") 37 | ErrUserInfoFailed = errors.New("user info failed") 38 | ErrUnauthorizedRedirectURI = errors.New("unauthorized redirect_uri") 39 | ErrInvalidFlow = errors.New("invalid OIDC flow") 40 | ErrUnsupportedChallengeMethod = errors.New("unsupported PKCE challenge method") 41 | ErrExpiredAuthTime = errors.New("expired auth_time") 42 | ErrMissingClaim = errors.New("missing required claim") 43 | ) 44 | -------------------------------------------------------------------------------- /oidc/examples/cli/.gitignore: -------------------------------------------------------------------------------- 1 | .env* 2 | cli -------------------------------------------------------------------------------- /oidc/examples/cli/README.md: -------------------------------------------------------------------------------- 1 | # cli 2 | 3 | An example OIDC user authentication CLI that supports both the authorization 4 | code (with optional PKCE) and implicit OIDC flows. 5 | 6 |
7 | 8 | ## Running the CLI 9 | ``` 10 | go build 11 | ``` 12 | Without any flags, the cli will invoke an authorization code authentication. 13 | ``` 14 | ./cli 15 | ``` 16 | 17 | With the `-pkce` flag, the cli will invoke an authorization code with PKCE authentication. 18 | ``` 19 | ./cli -pkce 20 | ``` 21 | 22 | With the `-implicit` flag, the cli will invoke an implicit flow authentication. 23 | ``` 24 | ./cli -implicit 25 | ``` 26 | 27 | With the `-max-age` flag, the cli will require an authentication not older than 28 | the max-age specified in seconds. 29 | ``` 30 | ./cli -max-age 31 | ``` 32 | ### Required environment variables 33 | (required if not using the built-in Test Provider. see note below on how-to use this option) 34 | 35 | * `OIDC_CLIENT_ID`: Your Relying Party client id. 36 | * `OIDC_CLIENT_SECRET`: Your Rely Party secret (this is not required for implicit 37 | flows or authorization code with PKCE flows) 38 | * `OIDC_ISSUER`: The OIDC issuer identifier (aka the discover URL) 39 | * `OIDC_PORT`: The port you'd like to use for your callback HTTP listener. 40 | 41 |
42 | 43 | ### OIDC Provider 44 | 45 | You must configure your provider's allowed callbacks to include: 46 | `http://localhost:{OIDC_PORT}/callback` (where OIDC_PORT equals whatever you've set 47 | the `OIDC_PORT` environment variable equal to). 48 | 49 | For example, if you set `OIDC_PORT` equal to 50 | `3000` the you must configure your provider to allow callbacks to: 51 | `http://localhost:3000/callback` 52 | 53 |
54 | 55 | ### OIDC Provider PKCE support. 56 | Many providers require you to explicitly enable the authorization code with 57 | PKCE. Auth0 for example requires you to set your application type as: Native or 58 | Single Page Application if you wish to use PKCE. 59 | 60 |
61 | 62 | ### Built-in Test Provider 63 | We've add support to use a built in Test OIDC Provider into the CLI example. 64 | You simply pass the `-use-test-provider` option on the CLI and the Test Provider 65 | will be configured and started on an available localhost port. The Test 66 | Provider only allows you to login with one user which is `alice` with a password 67 | of `fido`. This very simple Test Provider option removes the dependency of 68 | creating a test account with a "real" provider, if you just want to run the CLI 69 | and see it work. 70 | 71 | 72 | -------------------------------------------------------------------------------- /oidc/examples/cli/responses.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package main 5 | 6 | const successHTML = ` 7 | 8 | 9 | 10 | 11 | 12 | Authentication Succeeded 13 | 98 | 99 | 100 |
101 |
102 |
103 | 106 |
107 |
108 | Signed in via your OIDC provider 109 |
110 |

111 | You can now close this window. 112 |

113 |
114 |
115 |
116 |
117 | 118 | 119 | ` 120 | -------------------------------------------------------------------------------- /oidc/examples/spa/.gitignore: -------------------------------------------------------------------------------- 1 | .env* 2 | spa -------------------------------------------------------------------------------- /oidc/examples/spa/README.md: -------------------------------------------------------------------------------- 1 | # spa 2 | 3 | 4 | An example OIDC SPA (single page application) that supports both the authorization 5 | code (with optional PKCE) and implicit OIDC flows. 6 | 7 |
8 | 9 | 10 | ## Running the example app 11 | ``` 12 | go build 13 | ``` 14 | Without any flags, the app will use the authorization code flow. 15 | ``` 16 | ./spa 17 | ``` 18 | 19 | With the `-pkce` flag, the app will use the authorization code with PKCE flow. 20 | ``` 21 | ./spa -pkce 22 | ``` 23 | 24 | With the `-implicit` flag, the app will use the implicit flow. 25 | ``` 26 | ./spa -implicit 27 | ``` 28 | 29 | With the `-max-age` flag, the cli will require an authentication not older than 30 | the max-age specified in seconds. 31 | ``` 32 | ./cli -max-age 33 | ``` 34 | ### Require environment variables 35 | 36 | * OIDC_CLIENT_ID: Your Relying Party client id. 37 | * OIDC_CLIENT_SECRET: Your Rely Party secret (this is not required for implicit 38 | flows or authorization code with PKCE flows) 39 | * OIDC_ISSUER: The OIDC issuer identifier (aka the discover URL) 40 | * OIDC_PORT: The port you'd like to use for your callback HTTP listener. 41 | 42 |
43 | 44 | ### OIDC Provider 45 | 46 | You must configure your provider's allowed callbacks to include: 47 | `http://localhost:{OIDC_PORT}/callback` (where OIDC_PORT equals whatever you've set 48 | the `OIDC_PORT` environment variable equal to). 49 | 50 | For example, if you set `OIDC_PORT` equal to 51 | `3000` the you must configure your provider to allow callbacks to: `http://localhost:3000/callback` 52 | 53 | 54 |
55 | 56 | ### OIDC Provider PKCE support. 57 | Many providers require you to explicitly enable the authorization code with 58 | PKCE. Auth0 for example requires you to set your application type as: Native or 59 | Single Page Application if you wish to use PKCE. -------------------------------------------------------------------------------- /oidc/examples/spa/main.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "context" 8 | "flag" 9 | "fmt" 10 | "net" 11 | "net/http" 12 | "os" 13 | "os/signal" 14 | "strings" 15 | "time" 16 | 17 | "github.com/hashicorp/cap/oidc" 18 | ) 19 | 20 | // List of required configuration environment variables 21 | const ( 22 | clientID = "OIDC_CLIENT_ID" 23 | clientSecret = "OIDC_CLIENT_SECRET" 24 | issuer = "OIDC_ISSUER" 25 | port = "OIDC_PORT" 26 | attemptExp = "attemptExp" 27 | ) 28 | 29 | func envConfig(secretNotRequired bool) (map[string]interface{}, error) { 30 | const op = "envConfig" 31 | env := map[string]interface{}{ 32 | clientID: os.Getenv("OIDC_CLIENT_ID"), 33 | clientSecret: os.Getenv("OIDC_CLIENT_SECRET"), 34 | issuer: os.Getenv("OIDC_ISSUER"), 35 | port: os.Getenv("OIDC_PORT"), 36 | attemptExp: time.Duration(2 * time.Minute), 37 | } 38 | for k, v := range env { 39 | switch t := v.(type) { 40 | case string: 41 | switch k { 42 | case "OIDC_CLIENT_SECRET": 43 | switch { 44 | case secretNotRequired: 45 | env[k] = "" // unsetting the secret which isn't required 46 | case t == "": 47 | return nil, fmt.Errorf("%s: %s is empty.\n\n Did you intend to use -pkce or -implicit options?", op, k) 48 | } 49 | default: 50 | if t == "" { 51 | return nil, fmt.Errorf("%s: %s is empty", op, k) 52 | } 53 | } 54 | case time.Duration: 55 | if t == 0 { 56 | return nil, fmt.Errorf("%s: %s is empty", op, k) 57 | } 58 | default: 59 | return nil, fmt.Errorf("%s: %s is an unhandled type %t", op, k, t) 60 | } 61 | } 62 | return env, nil 63 | } 64 | 65 | func main() { 66 | useImplicit := flag.Bool("implicit", false, "use the implicit flow") 67 | usePKCE := flag.Bool("pkce", false, "use the implicit flow") 68 | maxAge := flag.Int("max-age", -1, "max age of user authentication") 69 | scopes := flag.String("scopes", "", "comma separated list of additional scopes to requests") 70 | 71 | flag.Parse() 72 | if *useImplicit && *usePKCE { 73 | fmt.Fprint(os.Stderr, "you can't request both: -implicit and -pkce") 74 | return 75 | } 76 | 77 | optScopes := strings.Split(*scopes, ",") 78 | for i := range optScopes { 79 | optScopes[i] = strings.TrimSpace(optScopes[i]) 80 | } 81 | 82 | env, err := envConfig(*useImplicit || *usePKCE) 83 | if err != nil { 84 | fmt.Fprintf(os.Stderr, "%s\n\n", err) 85 | return 86 | } 87 | 88 | // handle ctrl-c while waiting for the callback 89 | sigintCh := make(chan os.Signal, 1) 90 | signal.Notify(sigintCh, os.Interrupt) 91 | defer signal.Stop(sigintCh) 92 | 93 | ctx, cancel := context.WithCancel(context.Background()) 94 | defer cancel() 95 | 96 | issuer := env[issuer].(string) 97 | clientID := env[clientID].(string) 98 | clientSecret := oidc.ClientSecret(env[clientSecret].(string)) 99 | redirectURL := fmt.Sprintf("http://localhost:%s/callback", env[port].(string)) 100 | timeout := env[attemptExp].(time.Duration) 101 | 102 | rc := newRequestCache() 103 | 104 | pc, err := oidc.NewConfig(issuer, clientID, clientSecret, []oidc.Alg{oidc.RS256}, []string{redirectURL}) 105 | if err != nil { 106 | fmt.Fprint(os.Stderr, err.Error()) 107 | return 108 | } 109 | 110 | p, err := oidc.NewProvider(pc) 111 | if err != nil { 112 | fmt.Fprint(os.Stderr, err.Error()) 113 | return 114 | } 115 | defer p.Done() 116 | 117 | if err != nil { 118 | fmt.Fprintf(os.Stderr, "error getting auth url: %s", err) 119 | return 120 | } 121 | 122 | callback, err := CallbackHandler(ctx, p, rc, *useImplicit) 123 | if err != nil { 124 | fmt.Fprintf(os.Stderr, "error creating callback handler: %s", err) 125 | return 126 | } 127 | 128 | var requestOptions []oidc.Option 129 | switch { 130 | case *useImplicit: 131 | requestOptions = append(requestOptions, oidc.WithImplicitFlow()) 132 | case *usePKCE: 133 | v, err := oidc.NewCodeVerifier() 134 | if err != nil { 135 | fmt.Fprint(os.Stderr, err.Error()) 136 | return 137 | } 138 | requestOptions = append(requestOptions, oidc.WithPKCE(v)) 139 | } 140 | if *maxAge >= 0 { 141 | requestOptions = append(requestOptions, oidc.WithMaxAge(uint(*maxAge))) 142 | } 143 | 144 | requestOptions = append(requestOptions, oidc.WithScopes(optScopes...)) 145 | 146 | // Set up callback handler 147 | http.HandleFunc("/callback", callback) 148 | http.HandleFunc("/login", LoginHandler(ctx, p, rc, timeout, redirectURL, requestOptions)) 149 | http.HandleFunc("/success", SuccessHandler(ctx, rc)) 150 | 151 | listener, err := net.Listen("tcp", fmt.Sprintf("localhost:%s", env[port])) 152 | if err != nil { 153 | fmt.Fprint(os.Stderr, err.Error()) 154 | return 155 | } 156 | defer listener.Close() 157 | 158 | srvCh := make(chan error) 159 | // Start local server 160 | go func() { 161 | fmt.Fprintf(os.Stderr, "Complete the login via your OIDC provider. Launching browser to:\n\n http://localhost:%s/login\n\n\n", env[port]) 162 | err := http.Serve(listener, nil) 163 | if err != nil && err != http.ErrServerClosed { 164 | srvCh <- err 165 | } 166 | }() 167 | 168 | // Wait for either the callback to finish, SIGINT to be received or up to 2 minutes 169 | select { 170 | case err := <-srvCh: 171 | fmt.Fprintf(os.Stderr, "server closed with error: %s", err.Error()) 172 | return 173 | case <-sigintCh: 174 | fmt.Fprintf(os.Stderr, "Interrupted") 175 | return 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /oidc/examples/spa/request_cache.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "sync" 10 | 11 | "github.com/hashicorp/cap/oidc" 12 | ) 13 | 14 | type extendedRequest struct { 15 | oidc.Request 16 | t oidc.Token 17 | } 18 | 19 | type requestCache struct { 20 | m sync.Mutex 21 | c map[string]extendedRequest 22 | } 23 | 24 | func newRequestCache() *requestCache { 25 | return &requestCache{ 26 | c: map[string]extendedRequest{}, 27 | } 28 | } 29 | 30 | // Read implements the callback.StateReader interface and will delete the state 31 | // before returning. 32 | func (rc *requestCache) Read(ctx context.Context, state string) (oidc.Request, error) { 33 | const op = "requestCache.Read" 34 | rc.m.Lock() 35 | defer rc.m.Unlock() 36 | if oidcRequest, ok := rc.c[state]; ok { 37 | if oidcRequest.IsExpired() { 38 | delete(rc.c, state) 39 | return nil, fmt.Errorf("%s: state %s not found", op, state) 40 | } 41 | return oidcRequest, nil 42 | } 43 | return nil, fmt.Errorf("%s: state %s not found", op, state) 44 | } 45 | 46 | func (rc *requestCache) Add(s oidc.Request) { 47 | rc.m.Lock() 48 | defer rc.m.Unlock() 49 | rc.c[s.State()] = extendedRequest{Request: s} 50 | } 51 | 52 | func (rc *requestCache) SetToken(id string, t oidc.Token) error { 53 | const op = "stateCache.SetToken" 54 | rc.m.Lock() 55 | defer rc.m.Unlock() 56 | if oidcRequest, ok := rc.c[id]; ok { 57 | if oidcRequest.IsExpired() { 58 | delete(rc.c, id) 59 | return fmt.Errorf("%s: state %s not found (expired)", op, id) 60 | } 61 | rc.c[id] = extendedRequest{Request: oidcRequest.Request, t: t} 62 | return nil 63 | } 64 | return fmt.Errorf("%s: %s not found", op, id) 65 | } 66 | 67 | func (rc *requestCache) Delete(id string) { 68 | rc.m.Lock() 69 | defer rc.m.Unlock() 70 | delete(rc.c, id) 71 | } 72 | -------------------------------------------------------------------------------- /oidc/examples/spa/route_callback.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "net/http" 10 | "os" 11 | 12 | "github.com/hashicorp/cap/oidc" 13 | "github.com/hashicorp/cap/oidc/callback" 14 | ) 15 | 16 | func CallbackHandler(ctx context.Context, p *oidc.Provider, rc *requestCache, withImplicit bool) (http.HandlerFunc, error) { 17 | if withImplicit { 18 | c, err := callback.Implicit(ctx, p, rc, successFn(ctx, rc), failedFn(ctx, rc)) 19 | if err != nil { 20 | return nil, fmt.Errorf("CallbackHandler: %w", err) 21 | } 22 | return c, nil 23 | } 24 | c, err := callback.AuthCode(ctx, p, rc, successFn(ctx, rc), failedFn(ctx, rc)) 25 | if err != nil { 26 | return nil, fmt.Errorf("CallbackHandler: %w", err) 27 | } 28 | return c, nil 29 | } 30 | 31 | func successFn(ctx context.Context, rc *requestCache) callback.SuccessResponseFunc { 32 | return func(state string, t oidc.Token, w http.ResponseWriter, req *http.Request) { 33 | oidcRequest, err := rc.Read(ctx, state) 34 | if err != nil { 35 | fmt.Fprintf(os.Stderr, "error reading state during successful response: %s\n", err) 36 | http.Error(w, err.Error(), http.StatusInternalServerError) 37 | return 38 | } 39 | if err := rc.SetToken(oidcRequest.State(), t); err != nil { 40 | fmt.Fprintf(os.Stderr, "error updating state during successful response: %s\n", err) 41 | http.Error(w, err.Error(), http.StatusInternalServerError) 42 | return 43 | } 44 | // Redirect to logged in page 45 | http.Redirect(w, req, fmt.Sprintf("/success?state=%s", state), http.StatusSeeOther) 46 | } 47 | } 48 | 49 | func failedFn(ctx context.Context, rc *requestCache) callback.ErrorResponseFunc { 50 | const op = "failedFn" 51 | return func(state string, r *callback.AuthenErrorResponse, e error, w http.ResponseWriter, req *http.Request) { 52 | var responseErr error 53 | defer func() { 54 | if _, err := w.Write([]byte(responseErr.Error())); err != nil { 55 | fmt.Fprintf(os.Stderr, "error writing failed response: %s\n", err) 56 | } 57 | }() 58 | 59 | if e != nil { 60 | fmt.Fprintf(os.Stderr, "callback error: %s\n", e.Error()) 61 | responseErr = e 62 | w.WriteHeader(http.StatusInternalServerError) 63 | return 64 | } 65 | if r != nil { 66 | fmt.Fprintf(os.Stderr, "callback error from oidc provider: %s\n", r) 67 | responseErr = fmt.Errorf("%s: callback error from oidc provider: %s", op, r) 68 | w.WriteHeader(http.StatusUnauthorized) 69 | return 70 | } 71 | responseErr = fmt.Errorf("%s: unknown error from callback", op) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /oidc/examples/spa/route_login.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "net/http" 10 | "os" 11 | "time" 12 | 13 | "github.com/hashicorp/cap/oidc" 14 | ) 15 | 16 | func LoginHandler(ctx context.Context, p *oidc.Provider, rc *requestCache, timeout time.Duration, redirectURL string, requestOptions []oidc.Option) http.HandlerFunc { 17 | return func(w http.ResponseWriter, r *http.Request) { 18 | oidcRequest, err := oidc.NewRequest(timeout, redirectURL, requestOptions...) 19 | if err != nil { 20 | fmt.Fprint(os.Stderr, err.Error()) 21 | return 22 | } 23 | rc.Add(oidcRequest) 24 | 25 | authURL, err := p.AuthURL(ctx, oidcRequest) 26 | if err != nil { 27 | fmt.Fprintf(os.Stderr, "error getting auth url: %s", err) 28 | return 29 | } 30 | http.Redirect(w, r, authURL, http.StatusTemporaryRedirect) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /oidc/examples/spa/route_success.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "context" 8 | "encoding/json" 9 | "fmt" 10 | "net/http" 11 | "os" 12 | "time" 13 | 14 | "github.com/hashicorp/cap/oidc" 15 | ) 16 | 17 | func SuccessHandler(ctx context.Context, rc *requestCache) http.HandlerFunc { 18 | const op = "SuccessHandler" 19 | return func(w http.ResponseWriter, r *http.Request) { 20 | state := r.FormValue("state") 21 | oidcRequest, err := rc.Read(ctx, state) 22 | if err != nil { 23 | fmt.Fprintf(os.Stderr, "error reading state during successful response: %s", err) 24 | http.Error(w, err.Error(), http.StatusInternalServerError) 25 | return 26 | } 27 | defer rc.Delete(state) 28 | extended, ok := oidcRequest.(extendedRequest) 29 | if !ok { 30 | err := fmt.Errorf("%s: not an extended state", op) 31 | fmt.Fprint(os.Stderr, err) 32 | http.Error(w, err.Error(), http.StatusInternalServerError) 33 | return 34 | } 35 | 36 | t := printableToken(extended.t) 37 | tokenData, err := json.MarshalIndent(t, "", " ") 38 | if err != nil { 39 | fmt.Fprint(os.Stderr, err) 40 | http.Error(w, err.Error(), http.StatusInternalServerError) 41 | return 42 | } 43 | if _, err := w.Write(tokenData); err != nil { 44 | http.Error(w, err.Error(), http.StatusInternalServerError) 45 | } 46 | } 47 | } 48 | 49 | type respToken struct { 50 | IDToken string 51 | AccessToken string 52 | RefreshToken string 53 | Expiry time.Time 54 | } 55 | 56 | // printableToken is needed because the oidc.Token redacts the IDToken, 57 | // AccessToken and RefreshToken 58 | func printableToken(t oidc.Token) respToken { 59 | return respToken{ 60 | IDToken: string(t.IDToken()), 61 | AccessToken: string(t.AccessToken()), 62 | RefreshToken: string(t.RefreshToken()), 63 | Expiry: t.Expiry(), 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /oidc/id.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package oidc 5 | 6 | import ( 7 | "fmt" 8 | 9 | "github.com/hashicorp/cap/oidc/internal/base62" 10 | ) 11 | 12 | // DefaultIDLength is the default length for generated IDs, which are used for 13 | // state and nonce parameters during OIDC flows. 14 | // 15 | // For ID length requirements see: 16 | // https://tools.ietf.org/html/rfc6749#section-10.10 17 | const DefaultIDLength = 20 18 | 19 | // NewID generates a ID with an optional prefix. The ID generated is suitable 20 | // for a Request's State or Nonce. The ID length will be DefaultIDLen, unless an 21 | // optional prefix is provided which will add the prefix's length + an 22 | // underscore. The WithPrefix, WithLen options are supported. 23 | // 24 | // For ID length requirements see: 25 | // https://tools.ietf.org/html/rfc6749#section-10.10 26 | func NewID(opt ...Option) (string, error) { 27 | const op = "NewID" 28 | opts := getIDOpts(opt...) 29 | id, err := base62.Random(opts.withLen) 30 | if err != nil { 31 | return "", fmt.Errorf("%s: unable to generate id: %w", op, err) 32 | } 33 | switch { 34 | case opts.withPrefix != "": 35 | return fmt.Sprintf("%s_%s", opts.withPrefix, id), nil 36 | default: 37 | return id, nil 38 | } 39 | } 40 | 41 | // idOptions is the set of available options. 42 | type idOptions struct { 43 | withPrefix string 44 | withLen int 45 | } 46 | 47 | // idDefaults is a handy way to get the defaults at runtime and 48 | // during unit tests. 49 | func idDefaults() idOptions { 50 | return idOptions{ 51 | withLen: DefaultIDLength, 52 | } 53 | } 54 | 55 | // getConfigOpts gets the defaults and applies the opt overrides passed 56 | // in. 57 | func getIDOpts(opt ...Option) idOptions { 58 | opts := idDefaults() 59 | ApplyOpts(&opts, opt...) 60 | return opts 61 | } 62 | 63 | // WithPrefix provides an optional prefix for an new ID. When this options is 64 | // provided, NewID will prepend the prefix and an underscore to the new 65 | // identifier. 66 | // 67 | // Valid for: ID 68 | func WithPrefix(prefix string) Option { 69 | return func(o interface{}) { 70 | if o, ok := o.(*idOptions); ok { 71 | o.withPrefix = prefix 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /oidc/id_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package oidc 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestNewID(t *testing.T) { 14 | t.Parallel() 15 | tests := []struct { 16 | name string 17 | opt []Option 18 | wantErr bool 19 | wantPrefix string 20 | wantLen int 21 | }{ 22 | { 23 | name: "no-prefix", 24 | wantLen: DefaultIDLength, 25 | }, 26 | { 27 | name: "with-prefix", 28 | opt: []Option{WithPrefix("alice")}, 29 | wantPrefix: "alice", 30 | wantLen: DefaultIDLength + len("alice_"), 31 | }, 32 | } 33 | for _, tt := range tests { 34 | t.Run(tt.name, func(t *testing.T) { 35 | assert, require := assert.New(t), require.New(t) 36 | got, err := NewID(tt.opt...) 37 | if tt.wantErr { 38 | require.Error(err) 39 | return 40 | } 41 | require.NoError(err) 42 | if tt.wantPrefix != "" { 43 | assert.Containsf(got, tt.wantPrefix, "NewID() = %v and wanted prefix %s", got, tt.wantPrefix) 44 | } 45 | assert.Equalf(tt.wantLen, len(got), "NewID() = %v, with len of %d and wanted len of %v", got, len(got), tt.wantLen) 46 | }) 47 | } 48 | } 49 | 50 | func Test_WithPrefix(t *testing.T) { 51 | t.Parallel() 52 | assert := assert.New(t) 53 | opts := getIDOpts(WithPrefix("alice")) 54 | testOpts := idDefaults() 55 | testOpts.withPrefix = "alice" 56 | assert.Equal(opts, testOpts) 57 | } 58 | -------------------------------------------------------------------------------- /oidc/id_token.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package oidc 5 | 6 | import ( 7 | "crypto/sha256" 8 | "crypto/sha512" 9 | "encoding/base64" 10 | "encoding/json" 11 | "fmt" 12 | "hash" 13 | 14 | "github.com/go-jose/go-jose/v3" 15 | ) 16 | 17 | // IDToken is an oidc id_token. 18 | // See https://openid.net/specs/openid-connect-core-1_0.html#IDToken. 19 | type IDToken string 20 | 21 | // RedactedIDToken is the redacted string or json for an oidc id_token. 22 | const RedactedIDToken = "[REDACTED: id_token]" 23 | 24 | // String will redact the token. 25 | func (t IDToken) String() string { 26 | return RedactedIDToken 27 | } 28 | 29 | // MarshalJSON will redact the token. 30 | func (t IDToken) MarshalJSON() ([]byte, error) { 31 | return json.Marshal(RedactedIDToken) 32 | } 33 | 34 | // Claims retrieves the IDToken claims. 35 | func (t IDToken) Claims(claims interface{}) error { 36 | const op = "IDToken.Claims" 37 | if len(t) == 0 { 38 | return fmt.Errorf("%s: id_token is empty: %w", op, ErrInvalidParameter) 39 | } 40 | if claims == nil { 41 | return fmt.Errorf("%s: claims interface is nil: %w", op, ErrNilParameter) 42 | } 43 | return UnmarshalClaims(string(t), claims) 44 | } 45 | 46 | // VerifyAccessToken verifies the at_hash claim of the id_token against the hash 47 | // of the access_token. 48 | // 49 | // It will return true when it can verify the access_token. It will return false 50 | // when it's unable to verify the access_token. 51 | // 52 | // It will return an error whenever it's possible to verify the access_token and 53 | // the verification fails. 54 | // 55 | // Note: while we support signing id_tokens with EdDSA, unfortunately the 56 | // access_token hash cannot be verified without knowing the key's curve. See: 57 | // https://bitbucket.org/openid/connect/issues/1125 58 | // 59 | // For more info about verifying access_tokens returned during an OIDC flow see: 60 | // https://openid.net/specs/openid-connect-core-1_0.html#CodeIDToken 61 | func (t IDToken) VerifyAccessToken(accessToken AccessToken) (bool, error) { 62 | const op = "VerifyAccessToken" 63 | canVerify, err := t.verifyHashClaim("at_hash", string(accessToken)) 64 | if err != nil { 65 | return canVerify, fmt.Errorf("%s: %w", op, err) 66 | } 67 | return canVerify, nil 68 | } 69 | 70 | // VerifyAuthorizationCode verifies the c_hash claim of the id_token against the 71 | // hash of the authorization code. 72 | // 73 | // It will return true when it can verify the authorization code. It will return 74 | // false when it's unable to verify the authorization code. 75 | // 76 | // It will return an error whenever it's possible to verify the authorization 77 | // code and the verification fails. 78 | // 79 | // Note: while we support signing id_tokens with EdDSA, unfortunately the 80 | // authorization code hash cannot be verified without knowing the key's curve. 81 | // See: https://bitbucket.org/openid/connect/issues/1125 82 | // 83 | // For more info about authorization code verification using the id_token's 84 | // c_hash claim see: 85 | // https://openid.net/specs/openid-connect-core-1_0.html#HybridIDToken 86 | func (t IDToken) VerifyAuthorizationCode(code string) (bool, error) { 87 | const op = "VerifyAccessToken" 88 | canVerify, err := t.verifyHashClaim("c_hash", code) 89 | if err != nil { 90 | return canVerify, fmt.Errorf("%s: %w", op, err) 91 | } 92 | return canVerify, nil 93 | } 94 | 95 | func (t IDToken) verifyHashClaim(claimName string, token string) (bool, error) { 96 | const op = "verifyHashClaim" 97 | var claims map[string]interface{} 98 | if err := t.Claims(&claims); err != nil { 99 | return false, fmt.Errorf("%s: %w", op, err) 100 | } 101 | tokenHash, ok := claims[claimName].(string) 102 | if !ok { 103 | return false, nil 104 | } 105 | 106 | jws, err := jose.ParseSigned(string(t)) 107 | if err != nil { 108 | return false, fmt.Errorf("%s: malformed jwt (%v): %w", op, err, ErrMalformedToken) 109 | } 110 | switch len(jws.Signatures) { 111 | case 0: 112 | return false, fmt.Errorf("%s: id_token not signed: %w", op, ErrTokenNotSigned) 113 | case 1: 114 | default: 115 | return false, fmt.Errorf("%s: multiple signatures on id_token not supported", op) 116 | } 117 | sig := jws.Signatures[0] 118 | if _, ok := supportedAlgorithms[Alg(sig.Header.Algorithm)]; !ok { 119 | return false, fmt.Errorf("%s: id_token signed with algorithm %q: %w", op, sig.Header.Algorithm, ErrUnsupportedAlg) 120 | } 121 | sigAlgorithm := Alg(sig.Header.Algorithm) 122 | 123 | var h hash.Hash 124 | switch sigAlgorithm { 125 | case RS256, ES256, PS256: 126 | h = sha256.New() 127 | case RS384, ES384, PS384: 128 | h = sha512.New384() 129 | case RS512, ES512, PS512: 130 | h = sha512.New() 131 | case EdDSA: 132 | return false, nil 133 | default: 134 | return false, fmt.Errorf("%s: unsupported signing algorithm %s: %w", op, sigAlgorithm, ErrUnsupportedAlg) 135 | } 136 | _, _ = h.Write([]byte(token)) // hash documents that Write will never return an error 137 | sum := h.Sum(nil)[:h.Size()/2] 138 | actual := base64.RawURLEncoding.EncodeToString(sum) 139 | if actual != tokenHash { 140 | switch claimName { 141 | case "at_hash": 142 | return false, fmt.Errorf("%s: %w", op, ErrInvalidAtHash) 143 | case "c_hash": 144 | return false, fmt.Errorf("%s: %w", op, ErrInvalidCodeHash) 145 | } 146 | } 147 | return true, nil 148 | } 149 | -------------------------------------------------------------------------------- /oidc/internal/base62/base62.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | // Package base62 provides utilities for working with base62 strings. 5 | // base62 strings will only contain characters: 0-9, a-z, A-Z 6 | package base62 7 | 8 | import ( 9 | "crypto/rand" 10 | "io" 11 | 12 | uuid "github.com/hashicorp/go-uuid" 13 | ) 14 | 15 | const ( 16 | charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" 17 | csLen = byte(len(charset)) 18 | ) 19 | 20 | // Random generates a random string using base-62 characters. 21 | // Resulting entropy is ~5.95 bits/character. 22 | func Random(length int) (string, error) { 23 | return RandomWithReader(length, rand.Reader) 24 | } 25 | 26 | // RandomWithReader generates a random string using base-62 characters and a given reader. 27 | // Resulting entropy is ~5.95 bits/character. 28 | func RandomWithReader(length int, reader io.Reader) (string, error) { 29 | if length == 0 { 30 | return "", nil 31 | } 32 | output := make([]byte, 0, length) 33 | 34 | // Request a bit more than length to reduce the chance 35 | // of needing more than one batch of random bytes 36 | batchSize := length + length/4 37 | 38 | for { 39 | buf, err := uuid.GenerateRandomBytesWithReader(batchSize, reader) 40 | if err != nil { 41 | return "", err 42 | } 43 | 44 | for _, b := range buf { 45 | // Avoid bias by using a value range that's a multiple of 62 46 | if b < (csLen * 4) { 47 | output = append(output, charset[b%csLen]) 48 | 49 | if len(output) == length { 50 | return string(output), nil 51 | } 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /oidc/internal/base62/base62_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package base62 5 | 6 | import ( 7 | "testing" 8 | ) 9 | 10 | func TestRandom(t *testing.T) { 11 | strings := make(map[string]struct{}) 12 | 13 | for i := 0; i < 100000; i++ { 14 | c, err := Random(16) 15 | if err != nil { 16 | t.Fatal(err) 17 | } 18 | if _, ok := strings[c]; ok { 19 | t.Fatalf("Unexpected duplicate string: %s", c) 20 | } 21 | strings[c] = struct{}{} 22 | 23 | } 24 | 25 | for i := 0; i < 3000; i++ { 26 | c, err := Random(i) 27 | if err != nil { 28 | t.Fatal(err) 29 | } 30 | if len(c) != i { 31 | t.Fatalf("Expected length %d, got: %d", i, len(c)) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /oidc/internal/strutils/strutils.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package strutils 5 | 6 | import "strings" 7 | 8 | // StrListContains looks for a string in a list of strings. 9 | func StrListContains(haystack []string, needle string) bool { 10 | for _, item := range haystack { 11 | if item == needle { 12 | return true 13 | } 14 | } 15 | return false 16 | } 17 | 18 | // RemoveDuplicatesStable removes duplicate and empty elements from a slice of 19 | // strings, preserving order (and case) of the original slice. 20 | // In all cases, strings are compared after trimming whitespace 21 | // If caseInsensitive, strings will be compared after ToLower() 22 | func RemoveDuplicatesStable(items []string, caseInsensitive bool) []string { 23 | itemsMap := make(map[string]bool, len(items)) 24 | deduplicated := make([]string, 0, len(items)) 25 | 26 | for _, item := range items { 27 | key := strings.TrimSpace(item) 28 | if caseInsensitive { 29 | key = strings.ToLower(key) 30 | } 31 | if key == "" || itemsMap[key] { 32 | continue 33 | } 34 | itemsMap[key] = true 35 | deduplicated = append(deduplicated, item) 36 | } 37 | return deduplicated 38 | } 39 | -------------------------------------------------------------------------------- /oidc/internal/strutils/strutils_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package strutils 5 | 6 | import ( 7 | "reflect" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestStrutil_ListContains(t *testing.T) { 14 | t.Parallel() 15 | require := require.New(t) 16 | haystack := []string{ 17 | "dev", 18 | "ops", 19 | "prod", 20 | "root", 21 | } 22 | require.False(StrListContains(haystack, "tubez")) 23 | require.True(StrListContains(haystack, "root")) 24 | } 25 | 26 | func TestStrUtil_RemoveDuplicatesStable(t *testing.T) { 27 | type tCase struct { 28 | input []string 29 | expect []string 30 | caseInsensitive bool 31 | } 32 | 33 | tCases := []tCase{ 34 | {[]string{}, []string{}, false}, 35 | {[]string{}, []string{}, true}, 36 | {[]string{"a", "b", "a"}, []string{"a", "b"}, false}, 37 | {[]string{"A", "b", "a"}, []string{"A", "b", "a"}, false}, 38 | {[]string{"A", "b", "a"}, []string{"A", "b"}, true}, 39 | {[]string{" ", "d", "c", "d"}, []string{"d", "c"}, false}, 40 | {[]string{"Z ", " z", " z ", "y"}, []string{"Z ", "y"}, true}, 41 | {[]string{"Z ", " z", " z ", "y"}, []string{"Z ", " z", "y"}, false}, 42 | } 43 | 44 | for _, tc := range tCases { 45 | actual := RemoveDuplicatesStable(tc.input, tc.caseInsensitive) 46 | 47 | if !reflect.DeepEqual(actual, tc.expect) { 48 | t.Fatalf("Bad testcase %#v, expected %v, got %v", tc, tc.expect, actual) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /oidc/options.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package oidc 5 | 6 | import ( 7 | "time" 8 | 9 | "github.com/coreos/go-oidc/v3/oidc" 10 | "github.com/hashicorp/cap/oidc/internal/strutils" 11 | ) 12 | 13 | // Option defines a common functional options type which can be used in a 14 | // variadic parameter pattern. 15 | type Option func(interface{}) 16 | 17 | // ApplyOpts takes a pointer to the options struct as a set of default options 18 | // and applies the slice of opts as overrides. 19 | func ApplyOpts(opts interface{}, opt ...Option) { 20 | for _, o := range opt { 21 | if o == nil { // ignore any nil Options 22 | continue 23 | } 24 | o(opts) 25 | } 26 | } 27 | 28 | // WithNow provides an optional func for determining what the current time it 29 | // is. 30 | // 31 | // Valid for: Config, Tk and Request 32 | func WithNow(now func() time.Time) Option { 33 | return func(o interface{}) { 34 | if now == nil { 35 | return 36 | } 37 | switch v := o.(type) { 38 | case *configOptions: 39 | v.withNowFunc = now 40 | case *tokenOptions: 41 | v.withNowFunc = now 42 | case *reqOptions: 43 | v.withNowFunc = now 44 | } 45 | } 46 | } 47 | 48 | // WithScopes provides an optional list of scopes. 49 | // 50 | // Valid for: Config and Request 51 | func WithScopes(scopes ...string) Option { 52 | return func(o interface{}) { 53 | if len(scopes) == 0 { 54 | return 55 | } 56 | switch v := o.(type) { 57 | case *configOptions: 58 | // configOptions already has the oidc.ScopeOpenID in its defaults. 59 | scopes = strutils.RemoveDuplicatesStable(scopes, false) 60 | v.withScopes = append(v.withScopes, scopes...) 61 | case *reqOptions: 62 | // need to prepend the oidc.ScopeOpenID 63 | ts := append([]string{oidc.ScopeOpenID}, scopes...) 64 | scopes = strutils.RemoveDuplicatesStable(ts, false) 65 | v.withScopes = append(v.withScopes, scopes...) 66 | } 67 | } 68 | } 69 | 70 | // WithAudiences provides an optional list of audiences. 71 | // 72 | // Valid for: Config and Request 73 | func WithAudiences(auds ...string) Option { 74 | return func(o interface{}) { 75 | if len(auds) == 0 { 76 | return 77 | } 78 | auds := strutils.RemoveDuplicatesStable(auds, false) 79 | switch v := o.(type) { 80 | case *configOptions: 81 | v.withAudiences = append(v.withAudiences, auds...) 82 | case *reqOptions: 83 | v.withAudiences = append(v.withAudiences, auds...) 84 | case *userInfoOptions: 85 | v.withAudiences = append(v.withAudiences, auds...) 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /oidc/options_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package oidc 5 | 6 | import ( 7 | "testing" 8 | "time" 9 | 10 | "github.com/coreos/go-oidc/v3/oidc" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestApplyOpts(t *testing.T) { 15 | // ApplyOpts testing is covered by other tests but we do have just more 16 | // more test to add here. 17 | // Let's make sure we don't panic on nil options 18 | anonymousOpts := struct { 19 | Names []string 20 | }{ 21 | nil, 22 | } 23 | ApplyOpts(anonymousOpts, nil) 24 | } 25 | 26 | func Test_WithNow(t *testing.T) { 27 | t.Parallel() 28 | testNow := func() time.Time { 29 | return time.Now().Add(-1 * time.Minute) 30 | } 31 | t.Run("tokenOptions", func(t *testing.T) { 32 | opts := getTokenOpts(WithNow(testNow)) 33 | testOpts := tokenDefaults() 34 | testOpts.withNowFunc = testNow 35 | testAssertEqualFunc(t, opts.withNowFunc, testNow, "now = %p,want %p", opts.withNowFunc, testNow) 36 | }) 37 | t.Run("reqOptions", func(t *testing.T) { 38 | opts := getReqOpts(WithNow(testNow)) 39 | testOpts := reqDefaults() 40 | testOpts.withNowFunc = testNow 41 | testAssertEqualFunc(t, opts.withNowFunc, testNow, "now = %p,want %p", opts.withNowFunc, testNow) 42 | }) 43 | } 44 | 45 | func Test_WithAudiences(t *testing.T) { 46 | t.Parallel() 47 | t.Run("configOptions", func(t *testing.T) { 48 | assert := assert.New(t) 49 | opts := getConfigOpts(WithAudiences("alice", "bob")) 50 | testOpts := configDefaults() 51 | testOpts.withAudiences = []string{"alice", "bob"} 52 | assert.Equal(opts, testOpts) 53 | 54 | opts = getConfigOpts(WithAudiences()) 55 | testOpts = configDefaults() 56 | testOpts.withAudiences = nil 57 | assert.Equal(opts, testOpts) 58 | }) 59 | t.Run("reqOptions", func(t *testing.T) { 60 | assert := assert.New(t) 61 | opts := getReqOpts(WithAudiences("alice", "bob")) 62 | testOpts := reqDefaults() 63 | testOpts.withAudiences = []string{"alice", "bob"} 64 | assert.Equal(opts, testOpts) 65 | 66 | opts = getReqOpts(WithAudiences()) 67 | testOpts = reqDefaults() 68 | testOpts.withAudiences = nil 69 | assert.Equal(opts, testOpts) 70 | }) 71 | } 72 | 73 | func Test_WithScopes(t *testing.T) { 74 | t.Parallel() 75 | t.Run("configOptions", func(t *testing.T) { 76 | assert := assert.New(t) 77 | opts := getConfigOpts(WithScopes("alice", "bob")) 78 | testOpts := configDefaults() 79 | testOpts.withScopes = []string{oidc.ScopeOpenID, "alice", "bob"} 80 | assert.Equal(opts, testOpts) 81 | 82 | opts = getConfigOpts(WithScopes()) 83 | testOpts = configDefaults() 84 | testOpts.withScopes = []string{oidc.ScopeOpenID} 85 | assert.Equal(opts, testOpts) 86 | }) 87 | t.Run("reqOptions", func(t *testing.T) { 88 | t.Parallel() 89 | assert := assert.New(t) 90 | opts := getReqOpts(WithScopes("alice", "bob")) 91 | testOpts := reqDefaults() 92 | testOpts.withScopes = []string{oidc.ScopeOpenID, "alice", "bob"} 93 | assert.Equal(opts, testOpts) 94 | 95 | opts = getReqOpts(WithScopes()) 96 | testOpts = reqDefaults() 97 | testOpts.withScopes = nil 98 | assert.Equal(opts, testOpts) 99 | }) 100 | } 101 | -------------------------------------------------------------------------------- /oidc/pkce_verifier.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package oidc 5 | 6 | import ( 7 | "crypto/sha256" 8 | "encoding/base64" 9 | "fmt" 10 | "regexp" 11 | 12 | "github.com/hashicorp/cap/oidc/internal/base62" 13 | ) 14 | 15 | // ChallengeMethod represents PKCE code challenge methods as defined by RFC 16 | // 7636. 17 | type ChallengeMethod string 18 | 19 | const ( 20 | // PKCE code challenge methods as defined by RFC 7636. 21 | // 22 | // See: https://tools.ietf.org/html/rfc7636#page-9 23 | S256 ChallengeMethod = "S256" // SHA-256 24 | ) 25 | 26 | // CodeVerifier represents an OAuth PKCE code verifier. 27 | // 28 | // See: https://tools.ietf.org/html/rfc7636#section-4.1 29 | type CodeVerifier interface { 30 | // Verifier returns the code verifier (see: 31 | // https://tools.ietf.org/html/rfc7636#section-4.1) 32 | Verifier() string 33 | 34 | // Challenge returns the code verifier's code challenge (see: 35 | // https://tools.ietf.org/html/rfc7636#section-4.2) 36 | Challenge() string 37 | 38 | // Method returns the code verifier's challenge method (see 39 | // https://tools.ietf.org/html/rfc7636#section-4.2) 40 | Method() ChallengeMethod 41 | 42 | // Copy returns a copy of the verifier 43 | Copy() CodeVerifier 44 | } 45 | 46 | // S256Verifier represents an OAuth PKCE code verifier that uses the S256 47 | // challenge method. It implements the CodeVerifier interface. 48 | type S256Verifier struct { 49 | verifier string 50 | challenge string 51 | method ChallengeMethod 52 | } 53 | 54 | // min len of 43 chars per https://tools.ietf.org/html/rfc7636#section-4.1 55 | const ( 56 | // min len of 43 chars per https://tools.ietf.org/html/rfc7636#section-4.1 57 | minVerifierLen = 43 58 | // max len of 128 chars per https://tools.ietf.org/html/rfc7636#section-4.1 59 | maxVerifierLen = 128 60 | ) 61 | 62 | // NewCodeVerifier creates a new CodeVerifier (*S256Verifier). 63 | // Supported options: WithVerifier 64 | // 65 | // See: https://tools.ietf.org/html/rfc7636#section-4.1 66 | func NewCodeVerifier(opt ...Option) (*S256Verifier, error) { 67 | const op = "NewCodeVerifier" 68 | var ( 69 | err error 70 | verifierData string 71 | ) 72 | opts := getPKCEOpts(opt...) 73 | switch { 74 | case opts.withVerifier != "": 75 | verifierData = opts.withVerifier 76 | default: 77 | var err error 78 | verifierData, err = base62.Random(minVerifierLen) 79 | if err != nil { 80 | return nil, fmt.Errorf("%s: unable to create verifier data %w", op, err) 81 | } 82 | } 83 | if err := verifierIsValid(verifierData); err != nil { 84 | return nil, fmt.Errorf("%s: %w", op, err) 85 | } 86 | v := &S256Verifier{ 87 | verifier: verifierData, // no need to encode it, since bas62.Random uses a limited set of characters. 88 | method: S256, 89 | } 90 | if v.challenge, err = CreateCodeChallenge(v); err != nil { 91 | return nil, fmt.Errorf("%s: unable to create code challenge: %w", op, err) 92 | } 93 | return v, nil 94 | } 95 | 96 | func verifierIsValid(v string) error { 97 | const op = "verifierIsValid" 98 | switch { 99 | case len(v) < minVerifierLen: 100 | return fmt.Errorf("%s: verifier length is less than %d", op, minVerifierLen) 101 | case len(v) > maxVerifierLen: 102 | return fmt.Errorf("%s: verifier length is greater than %d", op, maxVerifierLen) 103 | default: 104 | // check that the verifier is valid based on 105 | // https://datatracker.ietf.org/doc/html/rfc7636#section-4.1 106 | // Check for valid characters: A-Z, a-z, 0-9, -, _, ., ~ 107 | validChars := regexp.MustCompile(`^[A-Za-z0-9\-\._~]+$`) 108 | if !validChars.MatchString(v) { 109 | return fmt.Errorf("%s: verifier contains invalid characters", op) 110 | } 111 | } 112 | return nil 113 | } 114 | 115 | func (v *S256Verifier) Verifier() string { return v.verifier } // Verifier implements the CodeVerifier.Verifier() interface function. 116 | func (v *S256Verifier) Challenge() string { return v.challenge } // Challenge implements the CodeVerifier.Challenge() interface function. 117 | func (v *S256Verifier) Method() ChallengeMethod { return v.method } // Method implements the CodeVerifier.Method() interface function. 118 | 119 | // Copy returns a copy of the verifier. 120 | func (v *S256Verifier) Copy() CodeVerifier { 121 | return &S256Verifier{ 122 | verifier: v.verifier, 123 | challenge: v.challenge, 124 | method: v.method, 125 | } 126 | } 127 | 128 | // CreateCodeChallenge creates a code challenge from the verifier. Supported 129 | // ChallengeMethods: S256 130 | // 131 | // See: https://tools.ietf.org/html/rfc7636#section-4.2 132 | func CreateCodeChallenge(v CodeVerifier) (string, error) { 133 | // currently, we only support S256 134 | if v.Method() != S256 { 135 | return "", fmt.Errorf("CreateCodeChallenge: %s is invalid: %w", v.Method(), ErrUnsupportedChallengeMethod) 136 | } 137 | h := sha256.New() 138 | _, _ = h.Write([]byte(v.Verifier())) // hash documents that Write will never return an Error 139 | sum := h.Sum(nil) 140 | return base64.RawURLEncoding.EncodeToString(sum), nil 141 | } 142 | 143 | // pkceOptions is the set of available options. 144 | type pkceOptions struct { 145 | withVerifier string 146 | } 147 | 148 | // pkceDefaults is a handy way to get the defaults at runtime and 149 | // during unit tests. 150 | func pkceDefaults() pkceOptions { 151 | return pkceOptions{} 152 | } 153 | 154 | // getPKCEOpts gets the defaults and applies the opt overrides passed in. 155 | func getPKCEOpts(opt ...Option) pkceOptions { 156 | opts := pkceDefaults() 157 | ApplyOpts(&opts, opt...) 158 | return opts 159 | } 160 | 161 | // WithVerifier provides an optional verifier for the code verifier. When this 162 | // option is provided, NewCodeVerifier will use the provided verifier. Note the 163 | // verifier must use the base62 character set. 164 | // See: https://datatracker.ietf.org/doc/html/rfc7636#section-4.1 165 | // 166 | // Valid for: NewVerifier 167 | func WithVerifier(verifier string) Option { 168 | return func(o interface{}) { 169 | if o, ok := o.(*pkceOptions); ok { 170 | o.withVerifier = verifier 171 | } 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /oidc/pkce_verifier_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package oidc 5 | 6 | import ( 7 | "crypto/sha256" 8 | "encoding/base64" 9 | "errors" 10 | "testing" 11 | 12 | "github.com/hashicorp/cap/oidc/internal/base62" 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | ) 16 | 17 | func TestNewCodeVerifier(t *testing.T) { 18 | t.Run("basics", func(t *testing.T) { 19 | assert, require := assert.New(t), require.New(t) 20 | got, err := NewCodeVerifier() 21 | require.NoError(err) 22 | assert.Equal(minVerifierLen, len(got.verifier)) 23 | assert.Equal(S256, got.Method()) 24 | 25 | challenge, err := CreateCodeChallenge(got) 26 | require.NoError(err) 27 | assert.Equal(challenge, got.Challenge()) 28 | }) 29 | } 30 | 31 | func TestCreateCodeChallenge(t *testing.T) { 32 | calcHash := func(data []byte) string { 33 | h := sha256.New() 34 | _, _ = h.Write(data) 35 | sum := h.Sum(nil) 36 | return base64.RawURLEncoding.EncodeToString(sum) 37 | } 38 | t.Run("basics", func(t *testing.T) { 39 | assert, require := assert.New(t), require.New(t) 40 | v, err := NewCodeVerifier() 41 | require.NoError(err) 42 | challenge, err := CreateCodeChallenge(v) 43 | require.NoError(err) 44 | assert.Equal(calcHash([]byte(v.Verifier())), challenge) 45 | }) 46 | t.Run("invalid-method", func(t *testing.T) { 47 | assert, require := assert.New(t), require.New(t) 48 | v, err := NewCodeVerifier() 49 | require.NoError(err) 50 | v.method = ChallengeMethod("S512") 51 | challenge, err := CreateCodeChallenge(v) 52 | require.Error(err) 53 | assert.Empty(challenge) 54 | assert.True(errors.Is(err, ErrUnsupportedChallengeMethod)) 55 | }) 56 | } 57 | 58 | func Test_WithVerifier(t *testing.T) { 59 | t.Parallel() 60 | assert, require := assert.New(t), require.New(t) 61 | v, err := base62.Random(43) 62 | require.NoError(err) 63 | got, err := NewCodeVerifier(WithVerifier(v)) 64 | require.NoError(err) 65 | assert.Equal(v, got.Verifier()) 66 | 67 | // Test that the verifier is too short 68 | v, err = base62.Random(42) 69 | require.NoError(err) 70 | _, err = NewCodeVerifier(WithVerifier(v)) 71 | require.Error(err) 72 | assert.Contains(err.Error(), "verifier length is less than 43") 73 | 74 | // Test that the verifier is too long 75 | v, err = base62.Random(129) 76 | require.NoError(err) 77 | _, err = NewCodeVerifier(WithVerifier(v)) 78 | require.Error(err) 79 | assert.Contains(err.Error(), "verifier length is greater than 128") 80 | 81 | // Test that the verifier contains invalid characters 82 | v, err = base62.Random(43) 83 | require.NoError(err) 84 | v = v + "!" 85 | _, err = NewCodeVerifier(WithVerifier(v)) 86 | require.Error(err) 87 | assert.Contains(err.Error(), "verifier contains invalid characters") 88 | } 89 | -------------------------------------------------------------------------------- /oidc/prompt.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package oidc 5 | 6 | // Prompt is a string values that specifies whether the Authorization Server 7 | // prompts the End-User for reauthentication and consent. 8 | // 9 | // See: https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest 10 | type Prompt string 11 | 12 | const ( 13 | // Defined the Prompt values that specifies whether the Authorization Server 14 | // prompts the End-User for reauthentication and consent. 15 | // 16 | // See: https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest 17 | None Prompt = "none" 18 | Login Prompt = "login" 19 | Consent Prompt = "consent" 20 | SelectAccount Prompt = "select_account" 21 | ) 22 | -------------------------------------------------------------------------------- /oidc/refresh_token.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package oidc 5 | 6 | import "encoding/json" 7 | 8 | // RefreshToken is an oauth refresh_token. 9 | // See https://tools.ietf.org/html/rfc6749#section-1.5. 10 | type RefreshToken string 11 | 12 | // RedactedRefreshToken is the redacted string or json for an oauth refresh_token. 13 | const RedactedRefreshToken = "[REDACTED: refresh_token]" 14 | 15 | // String will redact the token. 16 | func (t RefreshToken) String() string { 17 | return RedactedRefreshToken 18 | } 19 | 20 | // MarshalJSON will redact the token. 21 | func (t RefreshToken) MarshalJSON() ([]byte, error) { 22 | return json.Marshal(RedactedRefreshToken) 23 | } 24 | -------------------------------------------------------------------------------- /oidc/refresh_token_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package oidc 5 | 6 | import ( 7 | "fmt" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestRefreshToken_String(t *testing.T) { 15 | t.Parallel() 16 | t.Run("redacted", func(t *testing.T) { 17 | assert := assert.New(t) 18 | const want = RedactedRefreshToken 19 | tk := RefreshToken("super secret token") 20 | assert.Equalf(want, tk.String(), "RefreshToken.String() = %v, want %v", tk.String(), want) 21 | }) 22 | } 23 | 24 | func TestRefreshToken_MarshalJSON(t *testing.T) { 25 | t.Parallel() 26 | t.Run("redacted", func(t *testing.T) { 27 | assert, require := assert.New(t), require.New(t) 28 | want := fmt.Sprintf(`"%s"`, RedactedRefreshToken) 29 | tk := RefreshToken("super secret token") 30 | got, err := tk.MarshalJSON() 31 | require.NoError(err) 32 | assert.Equalf([]byte(want), got, "RefreshToken.MarshalJSON() = %s, want %s", got, want) 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /oidc/token.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package oidc 5 | 6 | import ( 7 | "encoding/base64" 8 | "encoding/json" 9 | "fmt" 10 | "strings" 11 | "time" 12 | 13 | "golang.org/x/oauth2" 14 | ) 15 | 16 | // Token interface represents an OIDC id_token, as well as an Oauth2 17 | // access_token and refresh_token (including the the access_token expiry). 18 | type Token interface { 19 | // RefreshToken returns the Token's refresh_token. 20 | RefreshToken() RefreshToken 21 | 22 | // AccessToken returns the Token's access_token. 23 | AccessToken() AccessToken 24 | 25 | // IDToken returns the Token's id_token. 26 | IDToken() IDToken 27 | 28 | // Expiry returns the expiration of the access_token. 29 | Expiry() time.Time 30 | 31 | // Valid will ensure that the access_token is not empty or expired. 32 | Valid() bool 33 | 34 | // IsExpired returns true if the token has expired. Implementations should 35 | // support a time skew (perhaps TokenExpirySkew) when checking expiration. 36 | IsExpired() bool 37 | } 38 | 39 | // StaticTokenSource is a single function interface that defines a method to 40 | // create a oauth2.TokenSource that always returns the same token. Because the 41 | // token is never refreshed. A TokenSource can be used to when calling a 42 | // provider's UserInfo(), among other things. 43 | type StaticTokenSource interface { 44 | StaticTokenSource() oauth2.TokenSource 45 | } 46 | 47 | // Tk satisfies the Token interface and represents an Oauth2 access_token and 48 | // refresh_token (including the the access_token expiry), as well as an OIDC 49 | // id_token. The access_token and refresh_token may be empty. 50 | type Tk struct { 51 | idToken IDToken 52 | underlying *oauth2.Token 53 | 54 | // nowFunc is an optional function that returns the current time 55 | nowFunc func() time.Time 56 | } 57 | 58 | // ensure that Tk implements the Token interface 59 | var _ Token = (*Tk)(nil) 60 | 61 | // NewToken creates a new Token (*Tk). The IDToken is required and the 62 | // *oauth2.Token may be nil. Supports the WithNow option (with a default to 63 | // time.Now). 64 | func NewToken(i IDToken, t *oauth2.Token, opt ...Option) (*Tk, error) { 65 | // since oauth2 is part of stdlib we're not going to worry about it leaking 66 | // into our abstraction in this factory 67 | const op = "NewToken" 68 | if i == "" { 69 | return nil, fmt.Errorf("%s: id_token is empty: %w", op, ErrInvalidParameter) 70 | } 71 | opts := getTokenOpts(opt...) 72 | return &Tk{ 73 | idToken: i, 74 | underlying: t, 75 | nowFunc: opts.withNowFunc, 76 | }, nil 77 | } 78 | 79 | // AccessToken implements the Token.AccessToken() interface function and may 80 | // return an empty AccessToken. 81 | func (t *Tk) AccessToken() AccessToken { 82 | if t.underlying == nil { 83 | return "" 84 | } 85 | return AccessToken(t.underlying.AccessToken) 86 | } 87 | 88 | // RefreshToken implements the Token.RefreshToken() interface function and may 89 | // return an empty RefreshToken. 90 | func (t *Tk) RefreshToken() RefreshToken { 91 | if t.underlying == nil { 92 | return "" 93 | } 94 | return RefreshToken(t.underlying.RefreshToken) 95 | } 96 | 97 | // IDToken implements the IDToken.IDToken() interface function. 98 | func (t *Tk) IDToken() IDToken { return IDToken(t.idToken) } 99 | 100 | // TokenExpirySkew defines a time skew when checking a Token's expiration. 101 | const TokenExpirySkew = 10 * time.Second 102 | 103 | // Expiry implements the Token.Expiry() interface function and may return a 104 | // "zero" time if the token's AccessToken is empty. 105 | func (t *Tk) Expiry() time.Time { 106 | if t.underlying == nil { 107 | return time.Time{} 108 | } 109 | return t.underlying.Expiry 110 | } 111 | 112 | // StaticTokenSource returns a TokenSource that always returns the same token. 113 | // Because the provided token t is never refreshed. It will return nil, if the 114 | // t.AccessToken() is empty. 115 | func (t *Tk) StaticTokenSource() oauth2.TokenSource { 116 | if t.underlying == nil { 117 | return nil 118 | } 119 | return oauth2.StaticTokenSource(t.underlying) 120 | } 121 | 122 | // IsExpired will return true if the token's access token is expired or empty. 123 | func (t *Tk) IsExpired() bool { 124 | if t.underlying == nil { 125 | return true 126 | } 127 | if t.underlying.Expiry.IsZero() { 128 | return false 129 | } 130 | return t.underlying.Expiry.Round(0).Before(time.Now().Add(TokenExpirySkew)) 131 | } 132 | 133 | // Valid will ensure that the access_token is not empty or expired. It will 134 | // return false if t.AccessToken() is empty. 135 | func (t *Tk) Valid() bool { 136 | if t == nil || t.underlying == nil { 137 | return false 138 | } 139 | if t.underlying.AccessToken == "" { 140 | return false 141 | } 142 | return !t.IsExpired() 143 | } 144 | 145 | // now returns the current time using the optional nowFunc. 146 | func (t *Tk) now() time.Time { 147 | if t.nowFunc != nil { 148 | return t.nowFunc() 149 | } 150 | return time.Now() // fallback to this default 151 | } 152 | 153 | // tokenOptions is the set of available options for Token functions 154 | type tokenOptions struct { 155 | withNowFunc func() time.Time 156 | } 157 | 158 | // tokenDefaults is a handy way to get the defaults at runtime and during unit 159 | // tests. 160 | func tokenDefaults() tokenOptions { 161 | return tokenOptions{} 162 | } 163 | 164 | // getTokenOpts gets the token defaults and applies the opt overrides passed 165 | // in 166 | func getTokenOpts(opt ...Option) tokenOptions { 167 | opts := tokenDefaults() 168 | ApplyOpts(&opts, opt...) 169 | return opts 170 | } 171 | 172 | // UnmarshalClaims will retrieve the claims from the provided raw JWT token. 173 | func UnmarshalClaims(rawToken string, claims interface{}) error { 174 | const op = "UnmarshalClaims" 175 | parts := strings.Split(string(rawToken), ".") 176 | if len(parts) != 3 { 177 | return fmt.Errorf("%s: malformed jwt, expected 3 parts got %d: %w", op, len(parts), ErrInvalidParameter) 178 | } 179 | raw, err := base64.RawURLEncoding.DecodeString(parts[1]) 180 | if err != nil { 181 | return fmt.Errorf("%s: malformed jwt claims: %w", op, err) 182 | } 183 | if err := json.Unmarshal(raw, claims); err != nil { 184 | return fmt.Errorf("%s: unable to marshal jwt JSON: %w", op, err) 185 | } 186 | return nil 187 | } 188 | -------------------------------------------------------------------------------- /oidc/token_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package oidc 5 | 6 | import ( 7 | "errors" 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | "golang.org/x/oauth2" 14 | ) 15 | 16 | func TestNewToken(t *testing.T) { 17 | t.Parallel() 18 | _, priv := TestGenerateKeys(t) 19 | testJWT := testDefaultJWT(t, priv, 1*time.Minute, "123456789", nil) 20 | testAccessToken := "test_access_token" 21 | testRefreshToken := "test_refresh_token" 22 | testExpiry := time.Now().Add(1 * time.Minute) 23 | testUnderlying := &oauth2.Token{ 24 | AccessToken: testAccessToken, 25 | RefreshToken: testRefreshToken, 26 | Expiry: testExpiry, 27 | } 28 | 29 | testUnderlyingZeroExpiry := &oauth2.Token{ 30 | AccessToken: testAccessToken, 31 | RefreshToken: testRefreshToken, 32 | } 33 | testNow := func() time.Time { 34 | return time.Now().Add(-1 * time.Minute) 35 | } 36 | 37 | tests := []struct { 38 | name string 39 | idToken IDToken 40 | oauthToken *oauth2.Token 41 | opts []Option 42 | want *Tk 43 | wantNowFunc func() time.Time 44 | wantIDToken IDToken 45 | wantAccessToken AccessToken 46 | wantRefreshToken RefreshToken 47 | wantTokenSource oauth2.TokenSource 48 | wantExpiry time.Time 49 | wantExpired bool 50 | wantValid bool 51 | wantErr bool 52 | wantIsErr error 53 | }{ 54 | { 55 | name: "valid", 56 | idToken: IDToken(testJWT), 57 | oauthToken: testUnderlying, 58 | opts: []Option{WithNow(testNow)}, 59 | want: &Tk{ 60 | idToken: IDToken(testJWT), 61 | underlying: testUnderlying, 62 | nowFunc: testNow, 63 | }, 64 | wantIDToken: IDToken(testJWT), 65 | wantAccessToken: AccessToken(testAccessToken), 66 | wantRefreshToken: RefreshToken(testRefreshToken), 67 | wantTokenSource: oauth2.StaticTokenSource(testUnderlying), 68 | wantExpiry: testExpiry, 69 | wantExpired: false, 70 | wantValid: true, 71 | }, 72 | { 73 | name: "valid-def-now-func", 74 | idToken: IDToken(testJWT), 75 | oauthToken: testUnderlying, 76 | opts: []Option{}, 77 | want: &Tk{ 78 | idToken: IDToken(testJWT), 79 | underlying: testUnderlying, 80 | }, 81 | wantIDToken: IDToken(testJWT), 82 | wantAccessToken: AccessToken(testAccessToken), 83 | wantRefreshToken: RefreshToken(testRefreshToken), 84 | wantTokenSource: oauth2.StaticTokenSource(testUnderlying), 85 | wantExpiry: testExpiry, 86 | wantExpired: false, 87 | wantValid: true, 88 | }, 89 | { 90 | name: "valid-without-accessToken", 91 | idToken: IDToken(testJWT), 92 | want: &Tk{ 93 | idToken: IDToken(testJWT), 94 | }, 95 | wantIDToken: IDToken(testJWT), 96 | wantExpired: true, 97 | wantValid: false, 98 | }, 99 | { 100 | name: "valid-with-accessToken-and-zero-expiry", 101 | idToken: IDToken(testJWT), 102 | oauthToken: testUnderlyingZeroExpiry, 103 | want: &Tk{ 104 | idToken: IDToken(testJWT), 105 | underlying: testUnderlyingZeroExpiry, 106 | }, 107 | wantIDToken: IDToken(testJWT), 108 | wantAccessToken: AccessToken(testAccessToken), 109 | wantRefreshToken: RefreshToken(testRefreshToken), 110 | wantTokenSource: oauth2.StaticTokenSource(testUnderlyingZeroExpiry), 111 | wantExpired: false, 112 | wantValid: true, 113 | }, 114 | { 115 | name: "empty-idToken", 116 | idToken: IDToken(""), 117 | oauthToken: &oauth2.Token{ 118 | AccessToken: testAccessToken, 119 | }, 120 | wantErr: true, 121 | wantIsErr: ErrInvalidParameter, 122 | }, 123 | } 124 | for _, tt := range tests { 125 | t.Run(tt.name, func(t *testing.T) { 126 | assert, require := assert.New(t), require.New(t) 127 | got, err := NewToken(tt.idToken, tt.oauthToken, tt.opts...) 128 | if tt.wantErr { 129 | require.Error(err) 130 | assert.Truef(errors.Is(err, tt.wantIsErr), "wanted \"%s\" but got \"%s\"", tt.wantIsErr, err) 131 | return 132 | } 133 | require.NoError(err) 134 | assert.Equalf(tt.want.underlying, got.underlying, "NewToken() = %v, want %v", got.underlying, tt.want.underlying) 135 | assert.Equalf(tt.wantIDToken, got.IDToken(), "t.IDToken() = %v, want %v", tt.wantIDToken, got.IDToken()) 136 | assert.Equalf(tt.wantAccessToken, got.AccessToken(), "t.AccessToken() = %v, want %v", tt.wantAccessToken, got.AccessToken()) 137 | assert.Equalf(tt.wantRefreshToken, got.RefreshToken(), "t.RefreshToken() = %v, want %v", tt.wantRefreshToken, got.RefreshToken()) 138 | assert.Equalf(tt.wantExpiry, got.Expiry(), "t.Expiry() = %v, want %v", tt.wantExpiry, got.Expiry()) 139 | assert.Equalf(tt.wantTokenSource, got.StaticTokenSource(), "t.StaticTokenSource() = %v, want %v", tt.wantTokenSource, got.StaticTokenSource()) 140 | assert.Equalf(tt.wantExpired, got.IsExpired(), "t.Expired() = %v, want %v", tt.wantExpired, got.IsExpired()) 141 | assert.Equalf(tt.wantValid, got.Valid(), "t.Valid() = %v, want %v", tt.wantValid, got.Valid()) 142 | testAssertEqualFunc(t, tt.want.nowFunc, got.nowFunc, "now = %p,want %p", tt.want.nowFunc, got.nowFunc) 143 | }) 144 | } 145 | } 146 | 147 | func TestUnmarshalClaims(t *testing.T) { 148 | // UnmarshalClaims testing is covered by other tests but we do have just a 149 | // few more test to add here. 150 | t.Parallel() 151 | t.Run("jwt-without-3-parts", func(t *testing.T) { 152 | assert, require := assert.New(t), require.New(t) 153 | var claims map[string]interface{} 154 | jwt := "one.two" 155 | err := UnmarshalClaims(jwt, &claims) 156 | require.Error(err) 157 | assert.Truef(errors.Is(err, ErrInvalidParameter), "wanted \"%s\" but got \"%s\"", ErrInvalidParameter, err) 158 | }) 159 | } 160 | -------------------------------------------------------------------------------- /saml/README.md: -------------------------------------------------------------------------------- 1 | 2 | # [`saml package`](./saml) 3 | 4 | [![Go Reference](https://pkg.go.dev/badge/github.com/hashicorp/cap/saml.svg)](https://pkg.go.dev/github.com/hashicorp/cap/saml) 5 | 6 | A package for writing clients that integrate with SAML Providers. 7 | 8 | The SAML library orients mainly on the implementation profile for 9 | [federation interoperability](https://kantarainitiative.github.io/SAMLprofiles/fedinterop.html) 10 | (also known as interoperable SAML), a set of software conformance requirements 11 | intended to facilitate interoperability within the context of full mesh identity 12 | federations. It supports the Web Browser SSO profile with HTTP-Post and 13 | HTTP-Redirect as supported service bindings. The default SAML settings follow 14 | the requirements of the interoperable SAML 15 | [deployment profile](https://kantarainitiative.github.io/SAMLprofiles/saml2int.html#_service_provider_requirements). 16 | 17 | ## Example usage 18 | 19 | ```go 20 | // Create a new saml config providing the necessary provider information: 21 | cfg, err := saml.NewConfig(, , , options...) 22 | // handle error 23 | 24 | // Use the config to create the service provider: 25 | sp, err := saml.NewServiceProvider(cfg) 26 | // handle error 27 | 28 | // With the service provider you can create saml authentication requests: 29 | 30 | // Generate a saml auth request with HTTP Post-Binding 31 | template, err := sp.AuthRequestPost("relay state", options...) 32 | // handle error 33 | 34 | // Generate a saml auth request with HTTP Request-Binding 35 | redirectURL, err := sp.AuthRequestRedirect("relay state", options...) 36 | // handle error 37 | 38 | // Parsing a SAML response: 39 | r.ParseForm() 40 | samlResp := r.PostForm.Get("SAMLResponse") 41 | 42 | response, err := sp.ParseResponse(samlResp, "Response ID", options...) 43 | // handle error 44 | ``` 45 | 46 | You can find the full demo code in the [`saml/demo`](./saml/demo/main.go) 47 | package. 48 | -------------------------------------------------------------------------------- /saml/authn_request.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /saml/demo/.gitignore: -------------------------------------------------------------------------------- 1 | demo 2 | -------------------------------------------------------------------------------- /saml/demo/main.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "fmt" 8 | "html/template" 9 | "net/http" 10 | "os" 11 | 12 | "github.com/hashicorp/cap/saml" 13 | "github.com/hashicorp/cap/saml/handler" 14 | ) 15 | 16 | func main() { 17 | envs := map[string]string{ 18 | "entityID": os.Getenv("CAP_SAML_ENTITY_ID"), 19 | "acs": os.Getenv("CAP_SAML_ACS"), 20 | "metadata": os.Getenv("CAP_SAML_METADATA"), 21 | "metadata_xml": os.Getenv("CAP_SAML_METADATA_XML"), 22 | } 23 | 24 | var options []saml.Option 25 | if metaXML, ok := envs["metadata_xml"]; ok { 26 | options = append(options, saml.WithMetadataXML(metaXML)) 27 | } 28 | 29 | cfg, err := saml.NewConfig(envs["entityID"], envs["acs"], envs["metadata"], options...) 30 | exitOnError(err) 31 | 32 | sp, err := saml.NewServiceProvider(cfg) 33 | exitOnError(err) 34 | 35 | acsHandler, err := handler.ACSHandlerFunc(sp) 36 | exitOnError(err) 37 | 38 | redirectHandler, err := handler.RedirectBindingHandlerFunc(sp) 39 | exitOnError(err) 40 | 41 | postBindHandler, err := handler.PostBindingHandlerFunc(sp) 42 | exitOnError(err) 43 | 44 | metadataHandler, err := handler.MetadataHandlerFunc(sp) 45 | exitOnError(err) 46 | 47 | http.HandleFunc("/saml/acs", acsHandler) 48 | http.HandleFunc("/saml/auth/redirect", redirectHandler) 49 | http.HandleFunc("/saml/auth/post", postBindHandler) 50 | http.HandleFunc("/metadata", metadataHandler) 51 | http.HandleFunc("/login", func(w http.ResponseWriter, _ *http.Request) { 52 | ts, _ := template.New("sso").Parse( 53 | `
54 |
`, 55 | ) 56 | 57 | ts.Execute(w, nil) 58 | }) 59 | 60 | fmt.Println("Visit http://localhost:8000/login") 61 | 62 | err = http.ListenAndServe(":8000", nil) 63 | exitOnError(err) 64 | } 65 | 66 | func exitOnError(err error) { 67 | if err != nil { 68 | fmt.Printf("failed to run demo: %s", err.Error()) 69 | os.Exit(1) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /saml/error.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package saml 5 | 6 | import "errors" 7 | 8 | var ( 9 | ErrInternal = errors.New("internal error") 10 | ErrBindingUnsupported = errors.New("Configured binding unsupported by the IDP") 11 | ErrInvalidTLSCert = errors.New("invalid tls certificate") 12 | ErrInvalidParameter = errors.New("invalid parameter") 13 | ErrMissingAssertions = errors.New("missing assertions") 14 | ErrInvalidTime = errors.New("invalid time") 15 | ErrInvalidAudience = errors.New("invalid audience") 16 | ErrMissingSubject = errors.New("subject missing") 17 | ErrMissingAttributeStmt = errors.New("attribute statement missing") 18 | ErrInvalidSignature = errors.New("invalid signature") 19 | ) 20 | -------------------------------------------------------------------------------- /saml/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hashicorp/cap/saml 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/beevik/etree v1.2.0 7 | github.com/crewjam/go-xmlsec v0.0.0-20200414151428-d2b1a58f7262 8 | github.com/crewjam/saml v0.4.14 9 | github.com/hashicorp/go-uuid v1.0.3 10 | github.com/jonboulle/clockwork v0.4.0 11 | github.com/russellhaering/gosaml2 v0.9.1 12 | github.com/russellhaering/goxmldsig v1.4.0 13 | github.com/stretchr/testify v1.8.4 14 | ) 15 | 16 | require ( 17 | github.com/crewjam/errset v0.0.0-20160219153700-f78d65de925c // indirect 18 | github.com/davecgh/go-spew v1.1.1 // indirect 19 | github.com/ma314smith/signedxml v1.1.1 // indirect 20 | github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect 21 | github.com/pkg/errors v0.9.1 // indirect 22 | github.com/pmezard/go-difflib v1.0.0 // indirect 23 | golang.org/x/crypto v0.36.0 // indirect 24 | gopkg.in/yaml.v3 v3.0.1 // indirect 25 | ) 26 | 27 | replace github.com/ma314smith/signedxml v1.1.1 => github.com/moov-io/signedxml v1.1.1 28 | -------------------------------------------------------------------------------- /saml/go.sum: -------------------------------------------------------------------------------- 1 | github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= 2 | github.com/beevik/etree v1.2.0 h1:l7WETslUG/T+xOPs47dtd6jov2Ii/8/OjCldk5fYfQw= 3 | github.com/beevik/etree v1.2.0/go.mod h1:aiPf89g/1k3AShMVAzriilpcE4R/Vuor90y83zVZWFc= 4 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 5 | github.com/crewjam/errset v0.0.0-20160219153700-f78d65de925c h1:dCJ9oZ0VgnzJHR5BjkSrwkXA1USu483qlxBd0u29P8s= 6 | github.com/crewjam/errset v0.0.0-20160219153700-f78d65de925c/go.mod h1:XhiWL7J86xoqJ8+x2OA+AM2l9skQP2DZ0UOXQYVg7uI= 7 | github.com/crewjam/go-xmlsec v0.0.0-20200414151428-d2b1a58f7262 h1:3V8RSsB1mxeAfxMb7lGSd0HlCHhc/ElJj1peaJMAkyk= 8 | github.com/crewjam/go-xmlsec v0.0.0-20200414151428-d2b1a58f7262/go.mod h1:M9eHnKpImgRwzOFdlFQnbgJRqFwW/eX1cKAVobv03uE= 9 | github.com/crewjam/saml v0.4.14 h1:g9FBNx62osKusnFzs3QTN5L9CVA/Egfgm+stJShzw/c= 10 | github.com/crewjam/saml v0.4.14/go.mod h1:UVSZCf18jJkk6GpWNVqcyQJMD5HsRugBPf4I1nl2mME= 11 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 13 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU= 15 | github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 16 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 17 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 18 | github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= 19 | github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= 20 | github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= 21 | github.com/jonboulle/clockwork v0.3.0/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= 22 | github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4= 23 | github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc= 24 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 25 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 26 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 27 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 28 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 29 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 30 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 31 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 32 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 33 | github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= 34 | github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= 35 | github.com/moov-io/signedxml v1.1.1 h1:TQ2fK4DRCYv7agH+z6RjtnBTmEyYMAztFzuHIPtUJpg= 36 | github.com/moov-io/signedxml v1.1.1/go.mod h1:p+b4f/Wo/qKyew8fHW8VZOgsILWylyvvjdE68egzbwc= 37 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 38 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 39 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 40 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 41 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 42 | github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 43 | github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= 44 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 45 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 46 | github.com/russellhaering/gosaml2 v0.9.1 h1:H/whrl8NuSoxyW46Ww5lKPskm+5K+qYLw9afqJ/Zef0= 47 | github.com/russellhaering/gosaml2 v0.9.1/go.mod h1:ja+qgbayxm+0mxBRLMSUuX3COqy+sb0RRhIGun/W2kc= 48 | github.com/russellhaering/goxmldsig v1.3.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= 49 | github.com/russellhaering/goxmldsig v1.4.0 h1:8UcDh/xGyQiyrW+Fq5t8f+l2DLB1+zlhYzkPUJ7Qhys= 50 | github.com/russellhaering/goxmldsig v1.4.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= 51 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 52 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 53 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 54 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 55 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 56 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 57 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 58 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 59 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 60 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 61 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 62 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 63 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 64 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 65 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 66 | gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= 67 | gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= 68 | -------------------------------------------------------------------------------- /saml/handler/acs.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package handler 5 | 6 | import ( 7 | "fmt" 8 | "net/http" 9 | 10 | "github.com/hashicorp/cap/saml" 11 | ) 12 | 13 | // ACSHandlerFunc creates a handler function that handles a SAML 14 | // ACS request 15 | func ACSHandlerFunc(sp *saml.ServiceProvider) (http.HandlerFunc, error) { 16 | const op = "handler.ACSHandler" 17 | switch { 18 | case sp == nil: 19 | return nil, fmt.Errorf("%s: missing service provider", op) 20 | } 21 | return func(w http.ResponseWriter, r *http.Request) { 22 | r.ParseForm() 23 | samlResp := r.PostForm.Get("SAMLResponse") 24 | 25 | res, err := sp.ParseResponse(samlResp, "responseID", saml.InsecureSkipRequestIDValidation()) 26 | if err != nil { 27 | fmt.Println("failed to handle SAML response:", err.Error()) 28 | http.Error(w, "failed to handle SAML response", http.StatusUnauthorized) 29 | return 30 | } 31 | 32 | fmt.Fprintf(w, "Authenticated! %+v", res) 33 | }, nil 34 | } 35 | -------------------------------------------------------------------------------- /saml/handler/metadata.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package handler 5 | 6 | import ( 7 | "encoding/xml" 8 | "fmt" 9 | "net/http" 10 | 11 | "github.com/hashicorp/cap/saml" 12 | ) 13 | 14 | // MetadataHandlerFunc creates a handler function that handles a SAML 15 | // metadata request 16 | func MetadataHandlerFunc(sp *saml.ServiceProvider) (http.HandlerFunc, error) { 17 | const op = "handler.MetadataHandlerFunc" 18 | switch { 19 | case sp == nil: 20 | return nil, fmt.Errorf("%s: missing service provider", op) 21 | } 22 | return func(w http.ResponseWriter, _ *http.Request) { 23 | meta := sp.CreateMetadata() 24 | err := xml.NewEncoder(w).Encode(meta) 25 | if err != nil { 26 | http.Error(w, err.Error(), http.StatusInternalServerError) 27 | } 28 | }, nil 29 | } 30 | -------------------------------------------------------------------------------- /saml/handler/post_binding.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package handler 5 | 6 | import ( 7 | _ "embed" 8 | "fmt" 9 | "net/http" 10 | 11 | "github.com/hashicorp/cap/saml" 12 | ) 13 | 14 | // PostBindingHandlerFunc creates a handler function that handles a HTTP-POST binding SAML request. 15 | func PostBindingHandlerFunc(sp *saml.ServiceProvider) (http.HandlerFunc, error) { 16 | const op = "handler.PostBindingHandlerFunc" 17 | switch { 18 | case sp == nil: 19 | return nil, fmt.Errorf("%s: missing service provider", op) 20 | } 21 | return func(w http.ResponseWriter, _ *http.Request) { 22 | templ, _, err := sp.AuthnRequestPost("") 23 | if err != nil { 24 | http.Error( 25 | w, 26 | fmt.Sprintf("Failed to do SAML POST authentication request: %s", err.Error()), 27 | http.StatusInternalServerError, 28 | ) 29 | return 30 | } 31 | 32 | err = saml.WritePostBindingRequestHeader(w) 33 | if err != nil { 34 | http.Error( 35 | w, 36 | fmt.Sprintf( 37 | "failed to write content headers: %s", 38 | err.Error(), 39 | ), 40 | http.StatusInternalServerError, 41 | ) 42 | } 43 | 44 | _, err = w.Write(templ) 45 | if err != nil { 46 | http.Error( 47 | w, 48 | fmt.Sprintf( 49 | "failed to serve post binding request: %s", 50 | err.Error(), 51 | ), 52 | http.StatusInternalServerError, 53 | ) 54 | return 55 | } 56 | }, nil 57 | } 58 | -------------------------------------------------------------------------------- /saml/handler/redirect_binding.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package handler 5 | 6 | import ( 7 | "fmt" 8 | "net/http" 9 | 10 | "github.com/hashicorp/cap/saml" 11 | ) 12 | 13 | // RedirectBindingHandlerFunc creates a handler function that handles a SAML 14 | // redirect request. 15 | func RedirectBindingHandlerFunc(sp *saml.ServiceProvider) (http.HandlerFunc, error) { 16 | const op = "handler.RedirectBindingHandlerFunc" 17 | switch { 18 | case sp == nil: 19 | return nil, fmt.Errorf("%s: missing service provider", op) 20 | } 21 | return func(w http.ResponseWriter, r *http.Request) { 22 | redirectURL, _, err := sp.AuthnRequestRedirect("relayState") 23 | if err != nil { 24 | http.Error( 25 | w, 26 | fmt.Sprintf("failed to create SAML Authn Request: %s", err.Error()), 27 | http.StatusInternalServerError, 28 | ) 29 | return 30 | } 31 | 32 | redirect := redirectURL.String() 33 | 34 | fmt.Printf("Redirect URL: %s\n", redirect) 35 | 36 | http.Redirect(w, r, redirect, http.StatusFound) 37 | }, nil 38 | } 39 | -------------------------------------------------------------------------------- /saml/is_nil.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package saml 5 | 6 | import "reflect" 7 | 8 | // isNil reports if a is nil 9 | func isNil(a any) bool { 10 | if a == nil { 11 | return true 12 | } 13 | switch reflect.TypeOf(a).Kind() { 14 | case reflect.Ptr, reflect.Map, reflect.Chan, reflect.Slice, reflect.Func: 15 | return reflect.ValueOf(a).IsNil() 16 | } 17 | return false 18 | } 19 | -------------------------------------------------------------------------------- /saml/is_nil_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package saml 5 | 6 | import ( 7 | "fmt" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func Test_isNil(t *testing.T) { 14 | t.Parallel() 15 | 16 | var testErrNilPtr *testError 17 | var testMapNilPtr map[string]struct{} 18 | var testArrayNilPtr *[1]string 19 | var testChanNilPtr *chan string 20 | var testSliceNilPtr *[]string 21 | var testFuncNil func() 22 | 23 | var testChanString chan string 24 | 25 | tc := []struct { 26 | i any 27 | want bool 28 | }{ 29 | {i: &testError{}, want: false}, 30 | {i: testError{}, want: false}, 31 | {i: &map[string]struct{}{}, want: false}, 32 | {i: map[string]struct{}{}, want: false}, 33 | {i: [1]string{}, want: false}, 34 | {i: &[1]string{}, want: false}, 35 | {i: &testChanString, want: false}, 36 | {i: "string", want: false}, 37 | {i: []string{}, want: false}, 38 | {i: func() {}, want: false}, 39 | {i: nil, want: true}, 40 | {i: testErrNilPtr, want: true}, 41 | {i: testMapNilPtr, want: true}, 42 | {i: testArrayNilPtr, want: true}, 43 | {i: testChanNilPtr, want: true}, 44 | {i: testChanString, want: true}, 45 | {i: testSliceNilPtr, want: true}, 46 | {i: testFuncNil, want: true}, 47 | } 48 | 49 | for i, tc := range tc { 50 | t.Run(fmt.Sprintf("test #%d", i+1), func(t *testing.T) { 51 | assert := assert.New(t) 52 | assert.Equal(tc.want, isNil(tc.i)) 53 | }) 54 | } 55 | } 56 | 57 | type testError struct{} 58 | 59 | func (*testError) Error() string { return "error" } 60 | -------------------------------------------------------------------------------- /saml/models/core/request.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package core 5 | 6 | import ( 7 | "encoding/xml" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | // See 3.2.1 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf 13 | type StatusRequestType struct { 14 | RequestResponseCommon 15 | } 16 | 17 | // See 3.4.1 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf 18 | // TODO Finish this 19 | type AuthnRequest struct { 20 | XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:protocol AuthnRequest"` 21 | 22 | StatusRequestType 23 | 24 | Subject *Subject 25 | NameIDPolicy *NameIDPolicy `xml:",omitempty"` 26 | Conditions *Conditions 27 | RequestedAuthContext *RequestedAuthnContext 28 | Scoping *Scoping 29 | 30 | ForceAuthn bool `xml:",attr,omitempty"` 31 | IsPassive bool `xml:",attr,omitempty"` 32 | 33 | AssertionConsumerServiceIndex string `xml:",attr,omitempty"` 34 | AssertionConsumerServiceURL string `xml:",attr"` 35 | 36 | // A URI reference that identifies a SAML protocol binding to be used when 37 | // returning the Response message. 38 | ProtocolBinding ServiceBinding `xml:",attr"` 39 | 40 | AttributeConsumingServiceIndex string `xml:",attr,omitempty"` 41 | ProviderName string `xml:",attr,omitempty"` 42 | } 43 | 44 | // Subject specifies the requested subject of the resulting assertion(s). 45 | // If entirely omitted or if no identifier is included, the presenter of 46 | // the message is presumed to be the requested subject. 47 | // 48 | // See 2.4 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf 49 | type Subject struct { 50 | XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:assertion Subject"` 51 | 52 | SubjectConfirmation []*SubjectConfirmation 53 | 54 | BaseID *BaseID // optional 55 | NameID *NameID // optional 56 | EncryptedID *EncryptedID // optional 57 | } 58 | 59 | // See 2.4.1.1 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf 60 | type SubjectConfirmation struct { 61 | Method ConfirmationMethod `xml:",attr"` // required 62 | 63 | SubjectConfirmationData *SubjectConfirmationData // optional 64 | 65 | BaseID *BaseID // optional 66 | NameID *NameID // optional 67 | EncryptedID *EncryptedID // optional 68 | } 69 | 70 | // See 2.4.1.2 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf 71 | type SubjectConfirmationData struct { 72 | NotBefore time.Time `xml:",attr"` // optional 73 | NotOnOrAfter time.Time `xml:",attr"` // optional 74 | Recipient string `xml:",attr"` // optional 75 | InResponseTo string `xml:",attr"` // optional 76 | Address string `xml:",attr"` // optional 77 | } 78 | 79 | /* TODO: Create a function to validate this: 80 | Note that the time period specified by the optional NotBefore and NotOnOrAfter attributes, if present, 81 | SHOULD fall within the overall assertion validity period as specified by the element's 82 | NotBefore and NotOnOrAfter attributes. If both attributes are present, the value for NotBefore 83 | MUST be less than (earlier than) the value for NotOnOrAfter. 84 | */ 85 | 86 | // NameIDPolicy specifies constraints on the name identifier to be used to represent 87 | // the requested subject. 88 | // See 3.4.1.1 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf 89 | type NameIDPolicy struct { 90 | Format NameIDFormat `xml:",omitempty"` 91 | SPNameQualifier string `xml:",attr,omitempty"` 92 | AllowCreate bool `xml:",attr"` 93 | } 94 | 95 | // Scoping ... (TODO: not important for the first MVP) 96 | // See 3.4.1.2 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf 97 | type Scoping struct { 98 | // ProxyCount specifies the number of proxying indirections permissible between the 99 | // identity provider that receives this AuthnRequest and the identity provider who 100 | // ultimately authenticates the principal. 101 | ProxyCount int `xml:",attr"` 102 | 103 | IDPList *IDPList 104 | 105 | RequesterID []string 106 | } 107 | 108 | // IDPList specifies the identity providers trusted by the requester to authenticate the 109 | // presenter. 110 | // See 3.4.1.3 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf 111 | type IDPList struct { 112 | IDPEntry []*IDPEntry 113 | GetComplete []string // TODO is this correct? 114 | } 115 | 116 | // IDPEntry specifies a single identity provider trusted by the requester to authenticate the 117 | // presenter. 118 | // See 3.4.1.3 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf 119 | type IDPEntry struct { 120 | // ProviderID is the unique identifier of the identity provider. 121 | ProviderID string `xml:",attr"` 122 | 123 | // Name is a human-readable name for the identity provider. 124 | Name string 125 | 126 | // Loc is a URI reference representing the location of a profile-specific endpoint 127 | // supporting the authentication request protocol. 128 | Loc string 129 | } 130 | 131 | type Conditions struct{} 132 | 133 | // Comparison specifies the comparison method used to evaluate the requested context classes or statements. 134 | // Possible values: "exact", "minimum", "maximum", "better" 135 | type Comparison string 136 | 137 | const ( 138 | // ComparisonExact requires that the resulting authentication context in the authentication 139 | // statement MUST be the exact match of at least one of the authentication contexts specified. 140 | ComparisonExact Comparison = "exact" // default 141 | 142 | // ComparisonMin requires that the resulting authentication context in the authentication 143 | // statement MUST be at least as strong (as deemed by the responder) as one of the authentication 144 | // contexts specified. 145 | ComparsionMin Comparison = "minimum" 146 | 147 | // ComparisonMax requires that the resulting authentication context in the authentication 148 | // statement MUST be stronger (as deemed by the responder) than any one of the authentication contexts 149 | // specified. 150 | ComparsionMax Comparison = "maximum" 151 | 152 | // ComparisonBetter requires that the resulting authentication context in the authentication 153 | // statement MUST be as strong as possible (as deemed by the responder) without exceeding the strength 154 | // of at least one of the authentication contexts specified. 155 | ComparisonBetter Comparison = "better" 156 | ) 157 | 158 | // RequestedAuthnContext specifies the authentication context requirements of 159 | // authentication statements returned in response to a request or query. 160 | // See 3.3.2.2.1 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf 161 | type RequestedAuthnContext struct { 162 | XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:protocol RequestedAuthnContext"` 163 | 164 | AuthnContextClassRef []string `xml:"urn:oasis:names:tc:SAML:2.0:assertion AuthnContextClassRef"` 165 | Comparison Comparison `xml:",attr"` 166 | } 167 | 168 | type Extensions struct{} 169 | 170 | // CreateXMLDocument creates an AuthnRequest XML document. 171 | func (a *AuthnRequest) CreateXMLDocument(indent int) ([]byte, error) { 172 | return xml.MarshalIndent(a, "", strings.Repeat(" ", indent)) 173 | } 174 | -------------------------------------------------------------------------------- /saml/models/core/response.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package core 5 | 6 | import ( 7 | "github.com/russellhaering/gosaml2/types" 8 | ) 9 | 10 | // Response is a SAML Response element. 11 | // See 3.3.3 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf 12 | type Response struct { 13 | types.Response 14 | } 15 | 16 | // Assertions returns the assertions in the Response. 17 | func (r *Response) Assertions() []Assertion { 18 | assertions := make([]Assertion, 0, len(r.Response.Assertions)) 19 | for _, assertion := range r.Response.Assertions { 20 | assertions = append(assertions, Assertion{Assertion: assertion}) 21 | } 22 | 23 | return assertions 24 | } 25 | 26 | // Issuer returns the issuer of the Response if it exists. 27 | // Otherwise, it returns an empty string. 28 | func (r *Response) Issuer() string { 29 | if r.Response.Issuer == nil { 30 | return "" 31 | } 32 | 33 | return r.Response.Issuer.Value 34 | } 35 | 36 | // Assertion is a SAML Assertion element. 37 | // See 2.3.3 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf 38 | type Assertion struct { 39 | types.Assertion 40 | } 41 | 42 | // Attribute is a SAML Attribute element. 43 | // See 2.7.3.1 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf 44 | type Attribute struct { 45 | types.Attribute 46 | } 47 | 48 | // Issuer returns the issuer of the Assertion if it exists. 49 | // Otherwise, it returns an empty string. 50 | func (a *Assertion) Issuer() string { 51 | if a.Assertion.Issuer == nil { 52 | return "" 53 | } 54 | 55 | return a.Assertion.Issuer.Value 56 | } 57 | 58 | // SubjectNameID returns the value of the NameID element if it exists in 59 | // the Subject of the Assertion. Otherwise, it returns an empty string. 60 | func (a *Assertion) SubjectNameID() string { 61 | if a.Subject == nil || a.Subject.NameID == nil { 62 | return "" 63 | } 64 | 65 | return a.Subject.NameID.Value 66 | } 67 | 68 | // Attributes returns the attributes of the Assertion. If there is no 69 | // AttributeStatement or no contained Attributes, an empty list is returned. 70 | func (a *Assertion) Attributes() []Attribute { 71 | if a.AttributeStatement == nil { 72 | return []Attribute{} 73 | } 74 | 75 | attributes := make([]Attribute, 0, len(a.AttributeStatement.Attributes)) 76 | for _, attribute := range a.AttributeStatement.Attributes { 77 | attributes = append(attributes, Attribute{Attribute: attribute}) 78 | } 79 | 80 | return attributes 81 | } 82 | -------------------------------------------------------------------------------- /saml/models/metadata/common.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package metadata 5 | 6 | import "github.com/hashicorp/cap/saml/models/core" 7 | 8 | /* 9 | This file defines common types used in defining SAML v2.0 Metadata elements and 10 | Attributes. 11 | See 2.2 Common Types - http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf 12 | */ 13 | 14 | // EndpointType describes a SAML protocol binding endpoint at which a SAML entity can 15 | // be sent protocol messages. 16 | // See 2.2.2 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf 17 | type Endpoint struct { 18 | Binding core.ServiceBinding `xml:",attr"` 19 | Location string `xml:",attr"` 20 | ResponseLocation string `xml:",attr,omitempty"` 21 | } 22 | 23 | // IndexedEndpointType extends EndpointType with a pair of attributes to permit the 24 | // indexing of otherwise identical endpoints so that they can be referenced by protocol messages. 25 | // See 2.2.3 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf 26 | type IndexedEndpoint struct { 27 | Endpoint 28 | Index int `xml:"index,attr"` 29 | IsDefault bool `xml:"isDefault,attr,omitempty"` 30 | } 31 | 32 | // Localized is used to represent the SAML types: 33 | // - localizedName 34 | // - localizedURI 35 | // See 2.2.4 & 2.2.5 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf 36 | type Localized struct { 37 | Lang string `xml:"http://www.w3.org/XML/1998/namespace lang,attr"` 38 | Value string `xml:",chardata"` 39 | } 40 | -------------------------------------------------------------------------------- /saml/models/metadata/duration.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package metadata 5 | 6 | import ( 7 | "time" 8 | 9 | crewjamSaml "github.com/crewjam/saml" 10 | ) 11 | 12 | // Duration is a time.Duration that uses the xsd:duration format for text 13 | // marshalling and unmarshalling. 14 | type Duration time.Duration 15 | 16 | // MarshalText implements the encoding.TextMarshaler interface. 17 | func (d Duration) MarshalText() ([]byte, error) { 18 | return crewjamSaml.Duration(d).MarshalText() 19 | } 20 | 21 | // UnmarshalText implements the encoding.TextUnmarshaler interface. 22 | func (d *Duration) UnmarshalText(text []byte) error { 23 | cp := (*crewjamSaml.Duration)(d) 24 | return cp.UnmarshalText(text) 25 | } 26 | -------------------------------------------------------------------------------- /saml/models/metadata/duration_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package metadata 5 | 6 | import ( 7 | "errors" 8 | "strconv" 9 | "testing" 10 | "time" 11 | 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | var durationMarshalTests = []struct { 16 | in time.Duration 17 | expected []byte 18 | }{ 19 | {0, nil}, 20 | {time.Hour, []byte("PT1H")}, 21 | {-time.Hour, []byte("-PT1H")}, 22 | } 23 | 24 | func TestDuration(t *testing.T) { 25 | for i, testCase := range durationMarshalTests { 26 | t.Run(strconv.Itoa(i), func(t *testing.T) { 27 | actual, err := Duration(testCase.in).MarshalText() 28 | require.NoError(t, err) 29 | require.Equal(t, testCase.expected, actual) 30 | }) 31 | } 32 | } 33 | 34 | var durationUnmarshalTests = []struct { 35 | in []byte 36 | expected time.Duration 37 | err error 38 | }{ 39 | {nil, 0, nil}, 40 | {[]byte("-PT1H"), -time.Hour, nil}, 41 | {[]byte("P1D"), 24 * time.Hour, nil}, 42 | {[]byte("P1M"), 720 * time.Hour, nil}, 43 | {[]byte("PT1.S"), 0, errors.New("invalid duration (PT1.S)")}, 44 | } 45 | 46 | func TestDurationUnmarshal(t *testing.T) { 47 | for i, testCase := range durationUnmarshalTests { 48 | t.Run(strconv.Itoa(i), func(t *testing.T) { 49 | var actual Duration 50 | err := actual.UnmarshalText(testCase.in) 51 | if testCase.err == nil { 52 | require.NoError(t, err) 53 | } else { 54 | require.ErrorContains(t, err, testCase.err.Error()) 55 | } 56 | require.Equal(t, Duration(testCase.expected), actual) 57 | }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /saml/models/metadata/entity_descriptor.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package metadata 5 | 6 | import ( 7 | "time" 8 | 9 | "github.com/beevik/etree" 10 | dsig "github.com/russellhaering/goxmldsig/types" 11 | 12 | "github.com/hashicorp/cap/saml/models/core" 13 | ) 14 | 15 | type ContactType string 16 | 17 | const ( 18 | ContactTypeTechnical ContactType = "technical" 19 | ContactTypeSupport ContactType = "support" 20 | ContactTypeAdministrative ContactType = "administrative" 21 | ContactTypeBilling ContactType = "billing" 22 | ContactTypeOther ContactType = "other" 23 | ) 24 | 25 | type ProtocolSupportEnumeration string 26 | 27 | const ( 28 | ProtocolSupportEnumerationProtocol ProtocolSupportEnumeration = "urn:oasis:names:tc:SAML:2.0:protocol" 29 | ) 30 | 31 | // KeyType defines what the key is used for. 32 | // Possible values are "encryption" and "signing". 33 | // See 2.4.1.1 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf 34 | type KeyType string 35 | 36 | const ( 37 | KeyTypeEncryption KeyType = "encryption" 38 | KeyTypeSigning KeyType = "signing" 39 | ) 40 | 41 | // DescriptorCommon defines common fields used in Entity- and EntitiesDescriptor. 42 | type DescriptorCommon struct { 43 | ID string `xml:",attr,omitempty"` 44 | ValidUntil *time.Time `xml:"validUntil,attr,omitempty"` 45 | CacheDuration *Duration `xml:"cacheDuration,attr,omitempty"` 46 | Signature *dsig.Signature 47 | } 48 | 49 | // EntitiesDescriptor is a container that wraps one or more elements of 50 | // EntityDiscriptor. 51 | // See 2.3.1 in http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf 52 | type EntitiesDescriptor struct { 53 | DescriptorCommon 54 | 55 | Name string 56 | 57 | EntitiesDescriptor []*EntitiesDescriptor 58 | EntityDescriptor []*EntityDescriptor 59 | } 60 | 61 | // EntityDescriptor represents a system entity (IdP or SP) in metadata. 62 | // See 2.3.2 in http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf 63 | type EntityDescriptor struct { 64 | DescriptorCommon 65 | 66 | EntityID string `xml:"entityID,attr"` 67 | 68 | AffiliationDescriptor *AffiliationDescriptor 69 | Organization *Organization 70 | ContactPerson *ContactPerson 71 | AdditionalMetadataLocation []string 72 | } 73 | 74 | // Organization specifies basic information about an organization responsible for a SAML 75 | // entity or role. 76 | // See 2.3.2.1 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf 77 | type Organization struct { 78 | Extensions []*etree.Element 79 | OrganizationName []Localized 80 | OrganizationDisplayName []Localized 81 | OrganizationURL []Localized 82 | } 83 | 84 | // ContactPerson specifies basic contact information about a person responsible in some 85 | // capacity for a SAML entity or role. 86 | // See 2.3.2.2 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf 87 | type ContactPerson struct { 88 | ContactType ContactType `xml:",attr"` 89 | Extensions []*etree.Element 90 | Company string 91 | GivenName string 92 | SurName string 93 | EmailAddress []string 94 | TelephoneNumber []string 95 | } 96 | 97 | // RoleDescriptor is an abstract extension point that contains common descriptive 98 | // information intended to provide processing commonality across different roles. 99 | // See 2.4.1 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf 100 | type RoleDescriptor struct { 101 | DescriptorCommon 102 | 103 | ProtocolSupportEnumeration ProtocolSupportEnumeration `xml:"protocolSupportEnumeration,attr,omitempty"` 104 | ErrorURL string `xml:"errorURL,attr,omitempty"` 105 | KeyDescriptor []KeyDescriptor 106 | Organization *Organization 107 | ContactPerson []ContactPerson 108 | } 109 | 110 | // KeyDescriptor provides information about the cryptographic key(s) that an entity uses 111 | // to sign data or receive encrypted keys, along with additional cryptographic details. 112 | // See 2.4.1.1 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf 113 | type KeyDescriptor struct { 114 | Use KeyType `xml:"use,attr"` 115 | KeyInfo KeyInfo 116 | EncryptionMethod []EncryptionMethod 117 | } 118 | 119 | // KeyInfo directly or indireclty identifies a key. It defines the usage of the 120 | // XML Signature element. 121 | // See https://www.w3.org/TR/xmldsig-core1/#sec-KeyInfo 122 | type KeyInfo struct { 123 | dsig.KeyInfo 124 | KeyName string 125 | } 126 | 127 | // EncyrptionMethod describes the encryption algorithm applied to the cipher data. 128 | // See https://www.w3.org/TR/2002/REC-xmlenc-core-20021210/Overview.html#sec-EncryptionMethod 129 | type EncryptionMethod struct { 130 | Algorithm string `xml:"Algorithm,attr"` 131 | } 132 | 133 | // SSODescriptor is the common base type for concrete types such as 134 | // IDPSSODescriptor and SPSSODescriptor. 135 | // See 2.4.2 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf 136 | type SSODescriptor struct { 137 | RoleDescriptor 138 | 139 | ArtifactResolutionService []IndexedEndpoint 140 | SingleLogoutService []Endpoint 141 | ManageNameIDService []Endpoint 142 | NameIDFormat []core.NameIDFormat 143 | } 144 | 145 | // AuthnAuthorityDescriptor ... ??? TODO 146 | // See 2.4.5 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf 147 | type AuthnAuthorityDescriptor struct { 148 | RoleDescriptor 149 | 150 | AuthnQueryService []Endpoint 151 | AssertionIDRequestService []Endpoint 152 | NameIDFormats []core.NameIDFormat 153 | } 154 | 155 | type PDPDescriptor struct{} 156 | 157 | // AttributeAuthorityDescriptor is a compatibiity requirement 158 | // for supporting legacy or other SPs that rely on queries for 159 | // attributes. 160 | type AttributeAuthorityDescriptor struct{} 161 | 162 | // AffiliationDescriptor represents a group of other 163 | // entities, such as related service providers that 164 | // share a persistent NameID. 165 | type AffiliationDescriptor struct{} 166 | 167 | // X509Data contains one ore more identifiers of keys or X509 certifactes. 168 | // See https://www.w3.org/TR/xmldsig-core1/#sec-X509Data 169 | // type X509Data struct { 170 | // XMLName xml.Name `xml:"http://www.w3.org/2000/09/xmldsig# X509Certificate"` 171 | // Data string `xml:",chardata"` 172 | // } 173 | -------------------------------------------------------------------------------- /saml/models/metadata/idp_sso_descriptor.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package metadata 5 | 6 | import ( 7 | "encoding/xml" 8 | 9 | "github.com/hashicorp/cap/saml/models/core" 10 | ) 11 | 12 | // IDPSSODescriptor contains profiles specific to identity providers supporting SSO. 13 | // It extends the SSODescriptor type. 14 | // See 2.4.3 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf 15 | type IDPSSODescriptor struct { 16 | XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:metadata IDPSSODescriptor"` 17 | 18 | SSODescriptor 19 | 20 | WantAuthnRequestsSigned bool `xml:",attr"` 21 | SingleSignOnService []Endpoint 22 | NameIDMappingService []Endpoint // TODO test missing! 23 | AssertionIDRequestService []Endpoint // TODO test missing! 24 | AttributeProfile []string // TODO test missing! 25 | Attribute []Attribute 26 | } 27 | 28 | // EntityDescriptorIDPSSO is an EntityDescriptor that accommodates the IDPSSODescriptor 29 | // as descriptor field only. 30 | type EntityDescriptorIDPSSO struct { 31 | EntityDescriptor 32 | 33 | IDPSSODescriptor []*IDPSSODescriptor 34 | } 35 | 36 | func (e *EntityDescriptorIDPSSO) GetLocationForBinding(b core.ServiceBinding) (string, bool) { 37 | for _, isd := range e.IDPSSODescriptor { 38 | for _, ssos := range isd.SingleSignOnService { 39 | if ssos.Binding == b { 40 | return ssos.Location, true 41 | } 42 | } 43 | } 44 | 45 | return "", false 46 | } 47 | -------------------------------------------------------------------------------- /saml/models/metadata/sp_sso_descriptor.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package metadata 5 | 6 | import "encoding/xml" 7 | 8 | // EntityDescriptorSPSSO defines an EntityDescriptor type 9 | // that can accommodate an SPSSODescriptor. 10 | // This type can be usued specifically to describe SPSSO profiles. 11 | type EntityDescriptorSPSSO struct { 12 | XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:metadata EntityDescriptor"` 13 | 14 | EntityDescriptor 15 | 16 | SPSSODescriptor []*SPSSODescriptor 17 | } 18 | 19 | // SPSSODescriptor contains profiles specific to service providers. 20 | // It extends the SSODescriptor type. 21 | // See 2.4.4 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf 22 | type SPSSODescriptor struct { 23 | XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:metadata SPSSODescriptor"` 24 | 25 | SSODescriptor 26 | 27 | AuthnRequestsSigned bool `xml:",attr"` 28 | WantAssertionsSigned bool `xml:",attr"` 29 | AssertionConsumerService []IndexedEndpoint 30 | AttributeConsumingService []*AttributeConsumingService 31 | Attribute []Attribute 32 | } 33 | 34 | // AttributeConsumingService (ACS) is the location where an IdP will eventually send 35 | // the user at the SP. 36 | // See 2.4.4.1 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf 37 | type AttributeConsumingService struct { 38 | Index int `xml:",attr"` 39 | IsDefault bool `xml:"isDefault,attr"` 40 | ServiceName []Localized 41 | ServiceDescription []Localized 42 | RequestedAttribute []RequestedAttribute 43 | } 44 | 45 | // RequestedAttribute specifies a service providers interest in a specific 46 | // SAML attribute, including specific values. 47 | // See 2.4.4.2 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf 48 | type RequestedAttribute struct { 49 | Attribute 50 | IsRequired bool `xml:"isRequired,attr"` 51 | } 52 | 53 | // TODO: CORE This needs to be part of core? 54 | type Attribute struct { 55 | FriendlyName string `xml:",attr"` 56 | Name string `xml:",attr"` 57 | NameFormat string `xml:",attr"` 58 | AttributeValue []AttributeValue 59 | } 60 | 61 | // TODO: CORE 62 | type AttributeValue struct { 63 | Type string `xml:"http://www.w3.org/2001/XMLSchema-instance type,attr"` 64 | Value string `xml:",chardata"` 65 | NameID *NameID 66 | } 67 | 68 | // TODO: CORE 69 | type NameID struct { 70 | NameQualifier string `xml:",attr"` 71 | SPNameQualifier string `xml:",attr"` 72 | Format string `xml:",attr"` 73 | SPProvidedID string `xml:",attr"` 74 | Value string `xml:",chardata"` 75 | } 76 | -------------------------------------------------------------------------------- /saml/options.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package saml 5 | 6 | // Option defines a common functional options type which can be used in a 7 | // variadic parameter pattern. 8 | type Option func(interface{}) 9 | 10 | // ApplyOpts takes a pointer to the options struct as a set of default options 11 | // and applies the slice of opts as overrides. 12 | func ApplyOpts(opts interface{}, opt ...Option) { 13 | for _, o := range opt { 14 | if o == nil { // ignore any nil Options 15 | continue 16 | } 17 | o(opts) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tools/tools.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //go:build tools 5 | // +build tools 6 | 7 | // This file ensures tool dependencies are kept in sync. This is the 8 | // recommended way of doing this according to 9 | // https://github.com/golang/go/wiki/Modules#how-can-i-track-tool-dependencies-for-a-module 10 | // To install the following tools at the version used by this repo run: 11 | // $ make tools 12 | // or 13 | // $ go generate -tags tools tools/tools.go 14 | 15 | package tools 16 | 17 | // NOTE: This must not be indented, so to stop goimports from trying to be 18 | // helpful, it's separated out from the import block below. Please try to keep 19 | // them in the same order. 20 | //go:generate go install mvdan.cc/gofumpt 21 | 22 | import ( 23 | _ "mvdan.cc/gofumpt" 24 | ) 25 | -------------------------------------------------------------------------------- /util/util.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package util 5 | 6 | import ( 7 | "fmt" 8 | "io/ioutil" 9 | "os/exec" 10 | "runtime" 11 | "strings" 12 | 13 | "github.com/hashicorp/go-multierror" 14 | ) 15 | 16 | // IsWSL tests if the binary is being run in Windows Subsystem for Linux 17 | func IsWSL() (bool, error) { 18 | if runtime.GOOS == "darwin" || runtime.GOOS == "windows" { 19 | return false, nil 20 | } 21 | procData, err := ioutil.ReadFile("/proc/version") 22 | if err != nil { 23 | return false, fmt.Errorf("Unable to read /proc/version: %w", err) 24 | } 25 | 26 | cgroupData, err := ioutil.ReadFile("/proc/1/cgroup") 27 | if err != nil { 28 | return false, fmt.Errorf("Unable to read /proc/1/cgroup: %w", err) 29 | } 30 | 31 | isDocker := strings.Contains(strings.ToLower(string(cgroupData)), "/docker/") 32 | isLxc := strings.Contains(strings.ToLower(string(cgroupData)), "/lxc/") 33 | isMsLinux := strings.Contains(strings.ToLower(string(procData)), "microsoft") 34 | 35 | return isMsLinux && !(isDocker || isLxc), nil 36 | } 37 | 38 | // OpenURL opens the specified URL in the default browser of the user. Source: 39 | // https://stackoverflow.com/a/39324149/453290 40 | func OpenURL(url string) error { 41 | var cmd string 42 | var args []string 43 | 44 | var mErr *multierror.Error 45 | wsl, err := IsWSL() 46 | if err != nil { 47 | mErr = multierror.Append(err) 48 | } 49 | switch { 50 | case "windows" == runtime.GOOS || wsl: 51 | cmd = "cmd.exe" 52 | args = []string{"/c", "start"} 53 | url = strings.Replace(url, "&", "^&", -1) 54 | case "darwin" == runtime.GOOS: 55 | cmd = "open" 56 | default: // "linux", "freebsd", "openbsd", "netbsd" 57 | cmd = "xdg-open" 58 | } 59 | args = append(args, url) 60 | if err := exec.Command(cmd, args...).Start(); err != nil { 61 | mErr = multierror.Append(err) 62 | } 63 | return mErr.ErrorOrNil() 64 | } 65 | --------------------------------------------------------------------------------