├── .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 | [](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 | [](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 | [](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 | [](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 | [](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 |
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 |
--------------------------------------------------------------------------------