diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index d67930e7..76e69eec 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -39,11 +39,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 + uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4.0.0 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@168b99b3c22180941ae7dbdd5f5c9678ede476ba # v2 + uses: github/codeql-action/init@c6c77c8c2d62cfd5b2e8d548817fd3d1582ac744 # codeql-bundle-v2.14.5 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -54,7 +54,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@168b99b3c22180941ae7dbdd5f5c9678ede476ba # v2 + uses: github/codeql-action/autobuild@c6c77c8c2d62cfd5b2e8d548817fd3d1582ac744 # codeql-bundle-v2.14.5 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -68,4 +68,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@168b99b3c22180941ae7dbdd5f5c9678ede476ba # v2 + uses: github/codeql-action/analyze@c6c77c8c2d62cfd5b2e8d548817fd3d1582ac744 # codeql-bundle-v2.14.5 diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 00000000..f7ecb859 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,16 @@ +name: Go fuzz test +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] +jobs: + fuzz-lexer-test: + name: Fuzz escapeValue(...) test + runs-on: ubuntu-latest + steps: + # commit hash == v1.2.0 + - uses: jidicula/go-fuzz-action@4f24eed45b25214f31a9fe035ca68ea2c88c6a13 # v1.2.0 + with: + fuzz-time: 30s + fuzz-regexp: Fuzz_EscapeValue diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 22034d81..487f862d 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -12,21 +12,21 @@ jobs: strategy: fail-fast: true matrix: - go: ["1.20", "1.19", "1.18"] + go: ["1.21", "1.20", "1.19"] platform: [ubuntu-latest] # can not run in windows OS runs-on: ${{ matrix.platform }} steps: - name: Set up Go 1.x - uses: actions/setup-go@4d34df0c2316fe8122ab82dc22947d607c0c91f9 # v4.0.0 + uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: go-version: ${{ matrix.go }} - name: Check out code into the Go module directory - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 + uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4.0.0 - name: go mod package cache - uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 # v3.3.1 + uses: actions/cache@704facf57e6136b1bc63b828d79edcd491f0ee84 # v3.3.2 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ matrix.go }}-${{ hashFiles('tests/go.mod') }} diff --git a/.github/workflows/make-gen-delta.yml b/.github/workflows/make-gen-delta.yml new file mode 100644 index 00000000..17fe3b65 --- /dev/null +++ b/.github/workflows/make-gen-delta.yml @@ -0,0 +1,42 @@ +name: "make-gen-delta" +on: + - workflow_dispatch + - push + - workflow_call + +permissions: + contents: read + +jobs: + make-gen-delta: + name: "Check for uncommitted changes from make gen" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + with: + fetch-depth: '0' + - name: Determine Go version + id: get-go-version + # We use .go-version as our source of truth for current Go + # version, because "goenv" can react to it automatically. + run: | + echo "Building with Go $(cat .go-version)" + echo "go-version=$(cat .go-version)" >> "$GITHUB_OUTPUT" + - name: Set up Go + uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 + with: + go-version: "${{ steps.get-go-version.outputs.go-version }}" + - name: Running go mod tidy + run: | + go mod tidy + - name: Install Dependencies + run: | + make tools + - name: Running make gen + run: | + make gen + - name: Check for changes + run: | + git diff --exit-code + git status --porcelain + test -z "$(git status --porcelain)" diff --git a/CHANGELOG.md b/CHANGELOG.md index 57fd772a..70c1bf58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ Canonical reference for changes, improvements, and bugfixes for cap. +## 0.5.0 + +### Improvements + +* JWT + * Adds ability to specify more than one `KeySet` used for token validation (https://github.com/hashicorp/cap/pull/128) + +## 0.4.1 + +### Bug fixes + +* SAML + * Truncate issue instant to microseconds to support Microsoft Entra ID enterprise applications (https://github.com/hashicorp/cap/pull/126) + +## 0.4.0 + +### Features + +* SAML + * Adds support for SAML authentication (https://github.com/hashicorp/cap/pull/99). + +### Improvements + +* LDAP + * Add worker pool for LDAP token group lookups ([**PR**](https://github.com/hashicorp/cap/pull/98)) + ## 0.3.4 ### Bug fixes diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..e1f9fe83 --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +# Format Go files, ignoring files marked as generated through the header defined at +# https://pkg.go.dev/cmd/go#hdr-Generate_Go_files_by_processing_source +.PHONY: fmt +fmt: + gofumpt -w $$(find . -name '*.go') + +.PHONY: gen +gen: fmt copywrite + +.PHONY: copywrite +copywrite: + copywrite headers + +.PHONY: tools +tools: + go generate -tags tools tools/tools.go + go install github.com/hashicorp/copywrite@v0.15.0 \ No newline at end of file diff --git a/README.md b/README.md index 8b1c7138..b2577e49 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,53 @@ if result.Success { // user successfully authenticated... if len(result.Groups) > 0 { // we found some groups associated with the authenticated user... - } + } } ``` + +### [`saml package`](./saml) + +[![Go Reference](https://pkg.go.dev/badge/github.com/hashicorp/cap/saml.svg)](https://pkg.go.dev/github.com/hashicorp/cap/saml) + +A package for writing clients that integrate with SAML Providers. + +The SAML library orients mainly on the implementation profile for +[federation interoperability](https://kantarainitiative.github.io/SAMLprofiles/fedinterop.html) +(also known as interoperable SAML), a set of software conformance requirements +intended to facilitate interoperability within the context of full mesh identity +federations. It supports the Web Browser SSO profile with HTTP-Post and +HTTP-Redirect as supported service bindings. The default SAML settings follow +the requirements of the interoperable SAML +[deployment profile](https://kantarainitiative.github.io/SAMLprofiles/saml2int.html#_service_provider_requirements). + +#### Example usage + +```go + // Create a new saml config providing the necessary provider information: + cfg, err := saml.NewConfig(, , , options...) + // handle error + + // Use the config to create the service provider: + sp, err := saml.NewServiceProvider(cfg) + // handle error + + // With the service provider you can create saml authentication requests: + + // Generate a saml auth request with HTTP Post-Binding + template, err := sp.AuthRequestPost("relay state", options...) + // handle error + + // Generate a saml auth request with HTTP Request-Binding + redirectURL, err := sp.AuthRequestRedirect("relay state", options...) + // handle error + + // Parsing a SAML response: + r.ParseForm() + samlResp := r.PostForm.Get("SAMLResponse") + + response, err := sp.ParseResponse(samlResp, "Response ID", options...) + // handle error +``` + +You can find the full demo code in the [`saml/demo`](./saml/demo/main.go) +package. diff --git a/go.mod b/go.mod index 8334f18e..8d464484 100644 --- a/go.mod +++ b/go.mod @@ -4,28 +4,33 @@ go 1.20 require ( github.com/coreos/go-oidc/v3 v3.5.0 - github.com/go-jose/go-jose/v3 v3.0.0 + github.com/go-jose/go-jose/v3 v3.0.1 github.com/hashicorp/go-cleanhttp v0.5.2 github.com/hashicorp/go-hclog v1.4.0 github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-uuid v1.0.3 github.com/stretchr/testify v1.8.1 github.com/yhat/scrape v0.0.0-20161128144610-24b7890b0945 - golang.org/x/net v0.7.0 + golang.org/x/net v0.17.0 golang.org/x/oauth2 v0.5.0 - golang.org/x/text v0.7.0 + golang.org/x/text v0.14.0 + mvdan.cc/gofumpt v0.5.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/fatih/color v1.14.1 // indirect github.com/golang/protobuf v1.5.2 // indirect + github.com/google/go-cmp v0.5.9 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.17 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/crypto v0.6.0 // indirect - golang.org/x/sys v0.5.0 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/mod v0.10.0 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/tools v0.8.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/protobuf v1.28.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index e92c873d..36feff73 100644 --- a/go.sum +++ b/go.sum @@ -7,16 +7,19 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w= github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg= -github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo= +github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= +github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA= +github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -28,11 +31,11 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -44,6 +47,7 @@ github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPn github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -60,9 +64,11 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc= -golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk= +golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -70,13 +76,15 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= -golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= -golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/oauth2 v0.3.0/go.mod h1:rQrIauxkUhJ6CuwEXwymO2/eh4xz2ZWF1nBkcxS+tGk= golang.org/x/oauth2 v0.5.0 h1:HuArIo48skDwlrvM3sEdHXElYslAMsf3KwRkkW4MC4s= golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -90,8 +98,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= @@ -100,11 +108,13 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.8.0 h1:vSDcovVPld282ceKgDimkRSC8kpaH1dgyc9UMzlt84Y= +golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= @@ -120,3 +130,5 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +mvdan.cc/gofumpt v0.5.0 h1:0EQ+Z56k8tXjj/6TQD25BFNKQXpCvT0rnansIc7Ug5E= +mvdan.cc/gofumpt v0.5.0/go.mod h1:HBeVDtMKRZpXyxFciAirzdKklDlGu8aAy1wEbH5Y9js= diff --git a/jwt/docs.go b/jwt/docs.go index a5d5b7ea..8b0c4855 100644 --- a/jwt/docs.go +++ b/jwt/docs.go @@ -13,22 +13,22 @@ JOSE header validation provided by the the package includes the option to valida JWT signature verification is supported by providing keys from the following sources: - - JSON Web Key Set (JWKS) URL - - OIDC Discovery mechanism - - Local public keys + - JSON Web Key Set (JWKS) URL + - OIDC Discovery mechanism + - Local public keys JWT signature verification supports the following asymmetric algorithms as defined in https://www.rfc-editor.org/rfc/rfc7518.html#section-3.1: - - RS256: RSASSA-PKCS1-v1_5 using SHA-256 - - RS384: RSASSA-PKCS1-v1_5 using SHA-384 - - RS512: RSASSA-PKCS1-v1_5 using SHA-512 - - ES256: ECDSA using P-256 and SHA-256 - - ES384: ECDSA using P-384 and SHA-384 - - ES512: ECDSA using P-521 and SHA-512 - - PS256: RSASSA-PSS using SHA-256 and MGF1 with SHA-256 - - PS384: RSASSA-PSS using SHA-384 and MGF1 with SHA-384 - - PS512: RSASSA-PSS using SHA-512 and MGF1 with SHA-512 - - EdDSA: Ed25519 using SHA-512 + - RS256: RSASSA-PKCS1-v1_5 using SHA-256 + - RS384: RSASSA-PKCS1-v1_5 using SHA-384 + - RS512: RSASSA-PKCS1-v1_5 using SHA-512 + - ES256: ECDSA using P-256 and SHA-256 + - ES384: ECDSA using P-384 and SHA-384 + - ES512: ECDSA using P-521 and SHA-512 + - PS256: RSASSA-PSS using SHA-256 and MGF1 with SHA-256 + - PS384: RSASSA-PSS using SHA-384 and MGF1 with SHA-384 + - PS512: RSASSA-PSS using SHA-512 and MGF1 with SHA-512 + - EdDSA: Ed25519 using SHA-512 */ package jwt diff --git a/jwt/jwt.go b/jwt/jwt.go index e095d520..452df2ec 100644 --- a/jwt/jwt.go +++ b/jwt/jwt.go @@ -19,19 +19,27 @@ import ( const DefaultLeewaySeconds = 150 // Validator validates JSON Web Tokens (JWT) by providing signature -// verification and claims set validation. +// verification and claims set validation. Validator can contain either +// a single or multiple KeySets and will attempt to verify the JWT by iterating +// through the configured KeySets. type Validator struct { - keySet KeySet + keySets []KeySet } // NewValidator returns a Validator that uses the given KeySet to verify JWT signatures. -func NewValidator(keySet KeySet) (*Validator, error) { - if keySet == nil { - return nil, errors.New("keySet must not be nil") +func NewValidator(keySets ...KeySet) (*Validator, error) { + if len(keySets) <= 0 { + return nil, errors.New("must provide at least one key set") + } + + for _, keySet := range keySets { + if keySet == nil { + return nil, errors.New("keySet must not be nil") + } } return &Validator{ - keySet: keySet, + keySets: keySets, }, nil } @@ -116,9 +124,21 @@ func (v *Validator) ValidateAllowMissingIatNbfExp(ctx context.Context, token str } func (v *Validator) validateAll(ctx context.Context, token string, expected Expected, allowMissingIatExpNbf bool) (map[string]interface{}, error) { - // First, verify the signature to ensure subsequent validation is against verified claims - allClaims, err := v.keySet.VerifySignature(ctx, token) - if err != nil { + var allClaims map[string]interface{} + var err error + + // Ensure that the token is signed by at least one of the given key sets + var tokenVerified bool + for _, keySet := range v.keySets { + // First, verify the signature to ensure subsequent validation is against verified claims + allClaims, err = keySet.VerifySignature(ctx, token) + if err == nil { + tokenVerified = true + break + } + } + + if !tokenVerified { return nil, fmt.Errorf("error verifying token signature: %w", err) } diff --git a/jwt/jwt_test.go b/jwt/jwt_test.go index bd693173..84ea347f 100644 --- a/jwt/jwt_test.go +++ b/jwt/jwt_test.go @@ -7,6 +7,7 @@ import ( "context" "crypto/rand" "crypto/rsa" + "fmt" "strings" "testing" "time" @@ -17,16 +18,25 @@ import ( "github.com/hashicorp/cap/oidc" ) -var priv *rsa.PrivateKey +var ( + priv *rsa.PrivateKey + priv2 *rsa.PrivateKey +) func init() { // Generate a key to sign JWTs with throughout most test cases. - // It can be slow sometimes to generate a 4096-bit RSA key, so we only do it once. + // It can be slow sometimes to generate a 4096-bit RSA key, so we only + // generate the test keys once on initialization. var err error priv, err = rsa.GenerateKey(rand.Reader, 4096) if err != nil { panic(err) } + + priv2, err = rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + panic(err) + } } // TestValidator_Validate_Valid_JWT tests cases where a JWT is expected to be valid. @@ -564,7 +574,7 @@ func TestValidator_Validate_Invalid_JWT(t *testing.T) { func TestNewValidator(t *testing.T) { type args struct { - keySet func() KeySet + keySets func() []KeySet } tests := []struct { name string @@ -574,27 +584,53 @@ func TestNewValidator(t *testing.T) { { name: "new validator with keySet", args: args{ - keySet: func() KeySet { + keySets: func() []KeySet { ks, err := NewJSONWebKeySet(context.Background(), "https://issuer.com/"+wellKnownJWKS, "") require.NoError(t, err) - return ks + return []KeySet{ks} }, }, }, { name: "new validator with nil keySet", args: args{ - keySet: func() KeySet { + keySets: func() []KeySet { return nil }, }, wantErr: true, }, + { + name: "new validator with multiple keySets", + args: args{ + keySets: func() []KeySet { + ks, err := NewJSONWebKeySet(context.Background(), + "https://issuer.com/"+wellKnownJWKS, "") + require.NoError(t, err) + + ks2, err := NewJSONWebKeySet(context.Background(), + "https://issuer2.com/"+wellKnownJWKS, "") + return []KeySet{ks, ks2} + }, + }, + }, + { + name: "new validator with nil keySet in keySets", + args: args{ + keySets: func() []KeySet { + ks, err := NewJSONWebKeySet(context.Background(), + "https://issuer.com/"+wellKnownJWKS, "") + require.NoError(t, err) + return []KeySet{ks, nil} + }, + }, + wantErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := NewValidator(tt.args.keySet()) + got, err := NewValidator(tt.args.keySets()...) if tt.wantErr { require.Error(t, err) return @@ -605,6 +641,951 @@ func TestNewValidator(t *testing.T) { } } +// TestValidator_MultipleKeySets_Validate_Valid_JWT tests cases where a JWT is expected to be valid where the +// validator is initialized with multiple KeySets. +func TestValidator_MultipleKeySets_Validate_Valid_JWT(t *testing.T) { + tp := oidc.StartTestProvider(t, oidc.WithTestPort(8181)) + tp2 := oidc.StartTestProvider(t, oidc.WithTestPort(8182)) + + // Create the KeySet to be used to verify JWT signatures + keySet1, err := NewOIDCDiscoveryKeySet(context.Background(), tp.Addr(), tp.CACert()) + require.NoError(t, err) + + tp.SetSigningKeys(priv, priv.Public(), oidc.RS256, testKeyID) + + keySet2, err := NewOIDCDiscoveryKeySet(context.Background(), tp2.Addr(), tp2.CACert()) + require.NoError(t, err) + + testKeyID2 := fmt.Sprintf("%s-2", testKeyID) + tp2.SetSigningKeys(priv, priv2.Public(), oidc.RS256, testKeyID2) + + // Establish past, now, and future for validation of time related claims + now := time.Now() + nowUnix := float64(now.Unix()) + pastUnix := float64(now.Add(-2 * jwt.DefaultLeeway).Unix()) + futureUnix := float64(now.Add(2 * jwt.DefaultLeeway).Unix()) + + type args struct { + claims map[string]interface{} + token func(map[string]interface{}) string + expected Expected + } + tests := []struct { + name string + args args + }{ + { + name: "valid jwt with assertion on issuer claim", + args: args{ + claims: map[string]interface{}{ + "iss": "https://example.com/", + "iat": nowUnix, + "exp": futureUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv, string(RS256), claims, []byte(testKeyID)) + }, + expected: Expected{ + Issuer: "https://example.com/", + }, + }, + }, + { + name: "valid jwt with assertion on issuer claim from key set 2", + args: args{ + claims: map[string]interface{}{ + "iss": "https://example.com/", + "iat": nowUnix, + "exp": futureUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv2, string(RS256), claims, []byte(testKeyID2)) + }, + expected: Expected{ + Issuer: "https://example.com/", + }, + }, + }, + { + name: "valid jwt with assertion on subject claim", + args: args{ + claims: map[string]interface{}{ + "sub": "alice@example.com", + "iat": nowUnix, + "exp": futureUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv, string(RS256), claims, []byte(testKeyID)) + }, + expected: Expected{ + Subject: "alice@example.com", + }, + }, + }, + { + name: "valid jwt with assertion on subject claim from key set 2", + args: args{ + claims: map[string]interface{}{ + "sub": "alice@example.com", + "iat": nowUnix, + "exp": futureUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv2, string(RS256), claims, []byte(testKeyID2)) + }, + expected: Expected{ + Subject: "alice@example.com", + }, + }, + }, + { + name: "valid jwt with assertion on id claim", + args: args{ + claims: map[string]interface{}{ + "jti": "abc123", + "iat": nowUnix, + "exp": futureUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv, string(RS256), claims, []byte(testKeyID)) + }, + expected: Expected{ + ID: "abc123", + }, + }, + }, + { + name: "valid jwt with assertion on id claim from key set 2", + args: args{ + claims: map[string]interface{}{ + "jti": "abc123", + "iat": nowUnix, + "exp": futureUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv2, string(RS256), claims, []byte(testKeyID2)) + }, + expected: Expected{ + ID: "abc123", + }, + }, + }, + { + name: "valid jwt with assertion on audience claim", + args: args{ + claims: map[string]interface{}{ + "aud": []interface{}{"www.example.com", "www.other.com"}, + "iat": nowUnix, + "exp": futureUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv, string(RS256), claims, []byte(testKeyID)) + }, + expected: Expected{ + Audiences: []string{"www.example.com", "www.other.com"}, + }, + }, + }, + { + name: "valid jwt with assertion on audience claim from key set 2", + args: args{ + claims: map[string]interface{}{ + "aud": []interface{}{"www.example.com", "www.other.com"}, + "iat": nowUnix, + "exp": futureUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv2, string(RS256), claims, []byte(testKeyID2)) + }, + expected: Expected{ + Audiences: []string{"www.example.com", "www.other.com"}, + }, + }, + }, + { + name: "valid jwt with assertion on algorithm header parameter", + args: args{ + claims: map[string]interface{}{ + "iat": nowUnix, + "exp": futureUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv, string(RS512), claims, []byte(testKeyID)) + }, + expected: Expected{ + SigningAlgorithms: []Alg{RS512}, + }, + }, + }, + { + name: "valid jwt with assertion on algorithm header parameter from key set 2", + args: args{ + claims: map[string]interface{}{ + "iat": nowUnix, + "exp": futureUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv2, string(RS512), claims, []byte(testKeyID2)) + }, + expected: Expected{ + SigningAlgorithms: []Alg{RS512}, + }, + }, + }, + { + name: "valid jwt with assertions on all expected claims", + args: args{ + claims: map[string]interface{}{ + "iss": "https://example.com/", + "sub": "alice@example.com", + "jti": "abc123", + "aud": []interface{}{"www.example.com"}, + "iat": nowUnix, + "exp": futureUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv, string(RS256), claims, []byte(testKeyID)) + }, + expected: Expected{ + Issuer: "https://example.com/", + Subject: "alice@example.com", + ID: "abc123", + Audiences: []string{"www.example.com"}, + SigningAlgorithms: []Alg{RS256}, + }, + }, + }, + { + name: "valid jwt with assertions on all expected claims from key set 2", + args: args{ + claims: map[string]interface{}{ + "iss": "https://example.com/", + "sub": "alice@example.com", + "jti": "abc123", + "aud": []interface{}{"www.example.com"}, + "iat": nowUnix, + "exp": futureUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv2, string(RS256), claims, []byte(testKeyID2)) + }, + expected: Expected{ + Issuer: "https://example.com/", + Subject: "alice@example.com", + ID: "abc123", + Audiences: []string{"www.example.com"}, + SigningAlgorithms: []Alg{RS256}, + }, + }, + }, + { + name: "valid jwt with registered claims assertions skipped when empty", + args: args{ + claims: map[string]interface{}{ + "iss": "https://example.com/", + "sub": "alice@example.com", + "jti": "abc123", + "aud": []interface{}{"www.example.com"}, + "iat": nowUnix, + "exp": futureUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv, string(RS256), claims, []byte(testKeyID)) + }, + expected: Expected{}, + }, + }, + { + name: "valid jwt with registered claims assertions skipped when empty from key set 2", + args: args{ + claims: map[string]interface{}{ + "iss": "https://example.com/", + "sub": "alice@example.com", + "jti": "abc123", + "aud": []interface{}{"www.example.com"}, + "iat": nowUnix, + "exp": futureUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv2, string(RS256), claims, []byte(testKeyID2)) + }, + expected: Expected{}, + }, + }, + { + name: "valid jwt exp after exp leeway set", + args: args{ + claims: map[string]interface{}{ + "iat": nowUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv, string(RS256), claims, []byte(testKeyID)) + }, + expected: Expected{ + // The JWT exp would be invalid with exp leeway < 2 min + ExpirationLeeway: 2 * time.Minute, + ClockSkewLeeway: -1, + Now: func() time.Time { + return time.Unix(int64(futureUnix), 0) + }, + }, + }, + }, + { + name: "valid jwt exp after exp leeway set from key set 2", + args: args{ + claims: map[string]interface{}{ + "iat": nowUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv2, string(RS256), claims, []byte(testKeyID2)) + }, + expected: Expected{ + // The JWT exp would be invalid with exp leeway < 2 min + ExpirationLeeway: 2 * time.Minute, + ClockSkewLeeway: -1, + Now: func() time.Time { + return time.Unix(int64(futureUnix), 0) + }, + }, + }, + }, + { + name: "valid jwt nbf after nbf leeway set", + args: args{ + claims: map[string]interface{}{ + "exp": nowUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv, string(RS256), claims, []byte(testKeyID)) + }, + expected: Expected{ + // The JWT nbf would be invalid with nbf leeway < 2 min + NotBeforeLeeway: 2 * time.Minute, + ClockSkewLeeway: -1, + Now: func() time.Time { + return time.Unix(int64(pastUnix), 0) + }, + }, + }, + }, + { + name: "valid jwt nbf after nbf leeway set from key set 2", + args: args{ + claims: map[string]interface{}{ + "exp": nowUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv2, string(RS256), claims, []byte(testKeyID2)) + }, + expected: Expected{ + // The JWT nbf would be invalid with nbf leeway < 2 min + NotBeforeLeeway: 2 * time.Minute, + ClockSkewLeeway: -1, + Now: func() time.Time { + return time.Unix(int64(pastUnix), 0) + }, + }, + }, + }, + { + name: "valid jwt nbf after clock skew leeway", + args: args{ + claims: map[string]interface{}{ + "iat": pastUnix, + "nbf": nowUnix, + "exp": futureUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv, string(RS256), claims, []byte(testKeyID)) + }, + expected: Expected{ + // The JWT nbf would be invalid with clock skew leeway < 2 min + ClockSkewLeeway: 2 * time.Minute, + Now: func() time.Time { + return time.Unix(int64(pastUnix), 0) + }, + }, + }, + }, + { + name: "valid jwt nbf after clock skew leeway from key set 2", + args: args{ + claims: map[string]interface{}{ + "iat": pastUnix, + "nbf": nowUnix, + "exp": futureUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv2, string(RS256), claims, []byte(testKeyID2)) + }, + expected: Expected{ + // The JWT nbf would be invalid with clock skew leeway < 2 min + ClockSkewLeeway: 2 * time.Minute, + Now: func() time.Time { + return time.Unix(int64(pastUnix), 0) + }, + }, + }, + }, + { + name: "valid jwt exp after clock skew leeway", + args: args{ + claims: map[string]interface{}{ + "iat": pastUnix, + "nbf": pastUnix, + "exp": nowUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv, string(RS256), claims, []byte(testKeyID)) + }, + expected: Expected{ + // The JWT exp would be invalid with clock skew leeway < 2 min + ClockSkewLeeway: 2 * time.Minute, + Now: func() time.Time { + return time.Unix(int64(futureUnix), 0) + }, + }, + }, + }, + { + name: "valid jwt exp after clock skew leeway from key set 2", + args: args{ + claims: map[string]interface{}{ + "iat": pastUnix, + "nbf": pastUnix, + "exp": nowUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv2, string(RS256), claims, []byte(testKeyID2)) + }, + expected: Expected{ + // The JWT exp would be invalid with clock skew leeway < 2 min + ClockSkewLeeway: 2 * time.Minute, + Now: func() time.Time { + return time.Unix(int64(futureUnix), 0) + }, + }, + }, + }, + { + name: "valid jwt iat after clock skew leeway", + args: args{ + claims: map[string]interface{}{ + "iat": nowUnix, + "nbf": pastUnix, + "exp": futureUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv, string(RS256), claims, []byte(testKeyID)) + }, + expected: Expected{ + // The JWT iat would be invalid with clock skew leeway < 2 min + ClockSkewLeeway: 2 * time.Minute, + Now: func() time.Time { + return time.Unix(int64(pastUnix), 0) + }, + }, + }, + }, + { + name: "valid jwt iat after clock skew leeway from key set 2", + args: args{ + claims: map[string]interface{}{ + "iat": nowUnix, + "nbf": pastUnix, + "exp": futureUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv2, string(RS256), claims, []byte(testKeyID2)) + }, + expected: Expected{ + // The JWT iat would be invalid with clock skew leeway < 2 min + ClockSkewLeeway: 2 * time.Minute, + Now: func() time.Time { + return time.Unix(int64(pastUnix), 0) + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + + // Create the signed JWT with the given claims + token := tt.args.token(tt.args.claims) + + // Create the validator with the KeySet + v, err := NewValidator(keySet1, keySet2) + require.NoError(t, err) + + // Validate the JWT claims against expected values + got, err := v.Validate(ctx, token, tt.args.expected) + + // Expect to get back the same claims that were serialized in the JWT + require.NoError(t, err) + require.NotNil(t, got) + require.Equal(t, tt.args.claims, got) + }) + } +} + +func TestValidator_MultipleKeySets_NoExpIatNbf(t *testing.T) { + tp := oidc.StartTestProvider(t, oidc.WithTestPort(8181)) + tp2 := oidc.StartTestProvider(t, oidc.WithTestPort(8182)) + + // Create the KeySet to be used to verify JWT signatures + keySet1, err := NewOIDCDiscoveryKeySet(context.Background(), tp.Addr(), tp.CACert()) + require.NoError(t, err) + + tp.SetSigningKeys(priv, priv.Public(), oidc.RS256, testKeyID) + + keySet2, err := NewOIDCDiscoveryKeySet(context.Background(), tp2.Addr(), tp2.CACert()) + require.NoError(t, err) + + testKeyID2 := fmt.Sprintf("%s-2", testKeyID) + tp2.SetSigningKeys(priv, priv2.Public(), oidc.RS256, testKeyID2) + + type args struct { + claims map[string]interface{} + token func(map[string]interface{}) string + expected Expected + } + tests := []struct { + name string + args args + }{ + { + name: "valid jwt with assertion on issuer claim", + args: args{ + claims: map[string]interface{}{ + "iss": "https://example.com/", + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv, string(RS256), claims, []byte(testKeyID)) + }, + expected: Expected{ + Issuer: "https://example.com/", + }, + }, + }, + { + name: "valid jwt with assertion on issuer claim from key set 2", + args: args{ + claims: map[string]interface{}{ + "iss": "https://example.com/", + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv2, string(RS256), claims, []byte(testKeyID2)) + }, + expected: Expected{ + Issuer: "https://example.com/", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + + // Create the signed JWT with the given claims + token := tt.args.token(tt.args.claims) + + // Create the validator with the KeySet + v, err := NewValidator(keySet1, keySet2) + require.NoError(t, err) + + // Validate the JWT claims against expected values + got, err := v.ValidateAllowMissingIatNbfExp(ctx, token, tt.args.expected) + + // Expect to get back the same claims that were serialized in the JWT + require.NoError(t, err) + require.NotNil(t, got) + require.Equal(t, tt.args.claims, got) + }) + } +} + +// TestValidator_MultipleKeySets_Validate_Invalid_JWT tests cases where a JWT is expected to be invalid where the +// validator is initialized with multiple KeySets. +func TestValidator_MultipleKeySets_Validate_Invalid_JWT(t *testing.T) { + tp := oidc.StartTestProvider(t, oidc.WithTestPort(8181)) + tp2 := oidc.StartTestProvider(t, oidc.WithTestPort(8182)) + + // Create the KeySet to be used to verify JWT signatures + keySet1, err := NewOIDCDiscoveryKeySet(context.Background(), tp.Addr(), tp.CACert()) + require.NoError(t, err) + + tp.SetSigningKeys(priv, priv.Public(), oidc.RS256, testKeyID) + + keySet2, err := NewOIDCDiscoveryKeySet(context.Background(), tp2.Addr(), tp2.CACert()) + require.NoError(t, err) + + testKeyID2 := fmt.Sprintf("%s-2", testKeyID) + tp2.SetSigningKeys(priv, priv2.Public(), oidc.RS256, testKeyID2) + + // Establish past, now, and future for validation of time related claims + now := time.Now() + nowUnix := float64(now.Unix()) + pastUnix := float64(now.Add(-2 * jwt.DefaultLeeway).Unix()) + futureUnix := float64(now.Add(2 * jwt.DefaultLeeway).Unix()) + + type args struct { + claims map[string]interface{} + token func(map[string]interface{}) string + expected Expected + } + tests := []struct { + name string + args args + }{ + { + name: "invalid jwt with assertion on issuer claim", + args: args{ + claims: map[string]interface{}{ + "iss": "https://example.com/", + "iat": nowUnix, + "exp": futureUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv, string(RS256), claims, []byte(testKeyID)) + }, + expected: Expected{ + Issuer: "https://wrong.com/", + }, + }, + }, + { + name: "invalid jwt with assertion on issuer claim from key set 2", + args: args{ + claims: map[string]interface{}{ + "iss": "https://example.com/", + "iat": nowUnix, + "exp": futureUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv2, string(RS256), claims, []byte(testKeyID2)) + }, + expected: Expected{ + Issuer: "https://wrong.com/", + }, + }, + }, + { + name: "invalid jwt with assertion on subject claim", + args: args{ + claims: map[string]interface{}{ + "sub": "alice@example.com", + "iat": nowUnix, + "exp": futureUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv, string(RS256), claims, []byte(testKeyID)) + }, + expected: Expected{ + Subject: "bob@example.com", + }, + }, + }, + { + name: "invalid jwt with assertion on subject claim from key set 2", + args: args{ + claims: map[string]interface{}{ + "sub": "alice@example.com", + "iat": nowUnix, + "exp": futureUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv2, string(RS256), claims, []byte(testKeyID2)) + }, + expected: Expected{ + Subject: "bob@example.com", + }, + }, + }, + { + name: "invalid jwt with assertion on id claim", + args: args{ + claims: map[string]interface{}{ + "jti": "abc123", + "iat": nowUnix, + "exp": futureUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv, string(RS256), claims, []byte(testKeyID)) + }, + expected: Expected{ + ID: "123abc", + }, + }, + }, + { + name: "invalid jwt with assertion on id claim from key set 2", + args: args{ + claims: map[string]interface{}{ + "jti": "abc123", + "iat": nowUnix, + "exp": futureUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv2, string(RS256), claims, []byte(testKeyID2)) + }, + expected: Expected{ + ID: "123abc", + }, + }, + }, + { + name: "invalid jwt with assertion on audience claim", + args: args{ + claims: map[string]interface{}{ + "aud": []interface{}{"www.other.com"}, + "iat": nowUnix, + "exp": futureUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv, string(RS256), claims, []byte(testKeyID)) + }, + expected: Expected{ + Audiences: []string{"www.example.com"}, + }, + }, + }, + { + name: "invalid jwt with assertion on audience claim from key set 2", + args: args{ + claims: map[string]interface{}{ + "aud": []interface{}{"www.other.com"}, + "iat": nowUnix, + "exp": futureUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv2, string(RS256), claims, []byte(testKeyID2)) + }, + expected: Expected{ + Audiences: []string{"www.example.com"}, + }, + }, + }, + { + name: "invalid jwt with assertion on algorithm header parameter", + args: args{ + claims: map[string]interface{}{ + "iat": nowUnix, + "exp": futureUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv, string(RS256), claims, []byte(testKeyID)) + }, + expected: Expected{ + SigningAlgorithms: []Alg{ES256}, + }, + }, + }, + { + name: "invalid jwt with assertion on algorithm header parameter from key set 2", + args: args{ + claims: map[string]interface{}{ + "iat": nowUnix, + "exp": futureUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv2, string(RS256), claims, []byte(testKeyID2)) + }, + expected: Expected{ + SigningAlgorithms: []Alg{ES256}, + }, + }, + }, + { + name: "invalid jwt from failed signature verification", + args: args{ + claims: map[string]interface{}{ + "iat": nowUnix, + "exp": futureUnix, + }, + token: func(claims map[string]interface{}) string { + // Sign the JWT with a key not in the test provider + pk, err := rsa.GenerateKey(rand.Reader, 4096) + require.NoError(t, err) + return oidc.TestSignJWT(t, pk, string(RS256), claims, []byte(testKeyID)) + }, + expected: Expected{ + SigningAlgorithms: []Alg{RS256}, + }, + }, + }, + { + name: "invalid jwt from failed signature verification from key set 2", + args: args{ + claims: map[string]interface{}{ + "iat": nowUnix, + "exp": futureUnix, + }, + token: func(claims map[string]interface{}) string { + // Sign the JWT with a key not in the test provider + pk, err := rsa.GenerateKey(rand.Reader, 4096) + require.NoError(t, err) + return oidc.TestSignJWT(t, pk, string(RS256), claims, []byte(testKeyID2)) + }, + expected: Expected{ + SigningAlgorithms: []Alg{RS256}, + }, + }, + }, + { + name: "invalid jwt with missing iat, nbf, and exp claims", + args: args{ + claims: map[string]interface{}{ + "iss": "https://example.com/", + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv, string(RS256), claims, []byte(testKeyID)) + }, + expected: Expected{}, + }, + }, + { + name: "invalid jwt with missing iat, nbf, and exp claims from key set 2", + args: args{ + claims: map[string]interface{}{ + "iss": "https://example.com/", + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv2, string(RS256), claims, []byte(testKeyID2)) + }, + expected: Expected{}, + }, + }, + { + name: "invalid jwt with now before nbf", + args: args{ + claims: map[string]interface{}{ + "nbf": nowUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv, string(RS256), claims, []byte(testKeyID)) + }, + expected: Expected{ + Now: func() time.Time { + return time.Unix(int64(pastUnix), 0) + }, + }, + }, + }, + { + name: "invalid jwt with now before nbf from key set 2", + args: args{ + claims: map[string]interface{}{ + "nbf": nowUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv2, string(RS256), claims, []byte(testKeyID2)) + }, + expected: Expected{ + Now: func() time.Time { + return time.Unix(int64(pastUnix), 0) + }, + }, + }, + }, + { + name: "invalid jwt with now after exp", + args: args{ + claims: map[string]interface{}{ + "exp": nowUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv, string(RS256), claims, []byte(testKeyID)) + }, + expected: Expected{ + Now: func() time.Time { + return time.Unix(int64(futureUnix), 0) + }, + }, + }, + }, + { + name: "invalid jwt with now after exp from key set 2", + args: args{ + claims: map[string]interface{}{ + "exp": nowUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv2, string(RS256), claims, []byte(testKeyID2)) + }, + expected: Expected{ + Now: func() time.Time { + return time.Unix(int64(futureUnix), 0) + }, + }, + }, + }, + { + name: "invalid jwt with now before iat", + args: args{ + claims: map[string]interface{}{ + "nbf": pastUnix, + "iat": futureUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv, string(RS256), claims, []byte(testKeyID)) + }, + expected: Expected{ + Now: func() time.Time { + return time.Unix(int64(nowUnix), 0) + }, + }, + }, + }, + { + name: "invalid jwt with now before iat from key set 2", + args: args{ + claims: map[string]interface{}{ + "nbf": pastUnix, + "iat": futureUnix, + }, + token: func(claims map[string]interface{}) string { + return oidc.TestSignJWT(t, priv2, string(RS256), claims, []byte(testKeyID2)) + }, + expected: Expected{ + Now: func() time.Time { + return time.Unix(int64(nowUnix), 0) + }, + }, + }, + }, + { + name: "invalid malformed jwt", + args: args{ + token: func(claims map[string]interface{}) string { + return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + + // Create the signed JWT with the given claims + token := tt.args.token(tt.args.claims) + + // Create the validator with the KeySet + v, err := NewValidator(keySet1, keySet2) + require.NoError(t, err) + + // Validate the JWT claims against expected values + got, err := v.Validate(ctx, token, tt.args.expected) + + // Expect an error and nil claims + require.Error(t, err) + require.Nil(t, got) + }) + } +} + func Test_validateAudience(t *testing.T) { type args struct { expectedAudiences []string diff --git a/jwt/keyset.go b/jwt/keyset.go index cfb3a022..a842f49a 100644 --- a/jwt/keyset.go +++ b/jwt/keyset.go @@ -28,7 +28,6 @@ import ( // KeySet represents a set of keys that can be used to verify the signatures of JWTs. // A KeySet is expected to be backed by a set of local or remote keys. type KeySet interface { - // VerifySignature parses the given JWT, verifies its signature, and returns the claims in its payload. // The given JWT must be of the JWS compact serialization form. VerifySignature(ctx context.Context, token string) (claims map[string]interface{}, err error) @@ -179,7 +178,7 @@ func (ks *staticKeySet) VerifySignature(_ context.Context, token string) (map[st // data must be of PEM-encoded x509 certificate or PKIX public key forms. It returns // an *rsa.PublicKey or *ecdsa.PublicKey. func ParsePublicKeyPEM(data []byte) (crypto.PublicKey, error) { - block, data := pem.Decode(data) + block, _ := pem.Decode(data) if block != nil { var rawKey interface{} var err error diff --git a/ldap/README.md b/ldap/README.md index 4f196c9a..b83150fa 100644 --- a/ldap/README.md +++ b/ldap/README.md @@ -1,4 +1,5 @@ # ldap + [![Go Reference](https://pkg.go.dev/badge/github.com/hashicorp/cap/ldap.svg)](https://pkg.go.dev/github.com/hashicorp/cap/ldap) ldap is a package for writing clients that authenticate using Active Directory @@ -11,7 +12,7 @@ Primary types provided by the package:
-## Examples: +## Examples * [CLI example](examples/cli/) which implements an ldap user authentication CLI. @@ -45,22 +46,24 @@ information on how to authenticate users, and instructions on how to query for group membership. The configuration options are categorized and detailed below. ### Connection parameters + * `URLS` ([]string, required) - The LDAP server to connect to. Examples: `ldap://ldap.myorg.com`, `ldaps://ldap.myorg.com:636`. If there's more than one URL configured, the directories will be tried in-order if there are errors during the connection process. * `StartTLS` (bool, optional) - If true, issues a StartTLS command after - establishing an unencrypted connection. + establishing an unencrypted connection. * `InsecureTLS` (bool, optional) - If true, skips LDAP server SSL certificate - verification - insecure, use with caution! + verification - insecure, use with caution! * `Certificate` (string, optional) - CA certificate to use when verifying LDAP - server certificate, must be x509 PEM encoded. + server certificate, must be x509 PEM encoded. * `ClientTLSCert` (string, optional) - Client certificate to provide to the LDAP - server, must be x509 PEM encoded. + server, must be x509 PEM encoded. * `ClientTLSKey` (string, optional) - Client certificate key to provide to the - LDAP server, must be x509 PEM encoded. + LDAP server, must be x509 PEM encoded. ### Binding parameters + There are two alternate methods of resolving the user object used to authenticate the end user: *Search* or *User Principal Name*. When using *Search*, the bind can be either anonymous or authenticated. *User Principal @@ -68,13 +71,14 @@ Name* is a method of specifying users supported by Active Directory. More information on UPN can be found [here](https://docs.microsoft.com/en-us/windows/win32/ad/naming-properties?redirectedfrom=MSDN#userPrincipalName). -**Binding - Authenticated Search** +#### Binding - Authenticated Search + * `BindDN` (string, optional) - Distinguished name of object to bind when performing user and group search. Example: `cn=application-acct,ou=Users,dc=example,dc=com` * `BindPassword` (string, optional) - Password to use along with binddn when - performing user search. + performing user search. * `UserDN` (string, optional) - Base DN under which to perform user search. - Example: `ou=Users,dc=example,dc=com` + Example: `ou=Users,dc=example,dc=com` * `UserAttr` (string, optional) - Attribute on user attribute object matching the username passed when authenticating. Examples: "cn", "uid" * `UserFilter` (string, optional) - Go template used to construct a ldap user @@ -87,7 +91,8 @@ information on UPN can be found could write `(&(objectClass=user)({{.UserAttr}}={{.Username}})(!(employeeType=Contractor)))`. -**Binding - Anonymous Search** +#### Binding - Anonymous Search + * `DiscoverDN` (bool, optional) - If true, use anonymous bind to discover the bind DN of a user * `UserDN` (string, optional) - Base DN under which to perform user search. Example: `ou=Users,dc=example,dc=com` @@ -96,13 +101,23 @@ information on UPN can be found * `AllowEmptyPasswordBinds` (bool, optional) - This option prevents users from bypassing authentication when providing an empty password. The default is `false`. * `AnonymousGroupSearch` (bool, optional) - Use anonymous binds when performing LDAP group searches. Defaults to `false`. -**Binding - User Principal Name (AD)** +#### Binding - User Principal Name (AD) + * `UPNDomain` (string, optional) - userPrincipalDomain used to construct the UPN string for the authenticating user. The constructed UPN will appear as `[username]@UPNDomain`. Example: `example.com`, which will result in binding as `username@example.com`. +#### Alias dereferencing + +* `DerefAliases` (string, optional) - Will control how aliases are dereferenced + when performing the search. Possible values are: `never`, `finding`, + `searching`, and `always`. If unset, a default of `never` is used. When set to + `finding`, it will only dereference aliases during name resolution of the + base. When set to `searching`, it will dereference aliases after name + resolution. ### Group Membership Resolution + Once a user has been authenticated, the LDAP auth method must know how to resolve which groups the user is a member of. The configuration for this can vary depending on your LDAP server and your directory schema. There are two main strategies when resolving group membership - the first is searching for the authenticated user object and following an attribute to groups it is a member of. The second is to search for group objects of which the authenticated user is a member of. Both methods are supported. * `GroupFilter` (string, optional) - Go template used when constructing the group membership query. The template can access the following context variables: [UserDN, Username]. The default is `(|(memberUid={{.Username}})(member={{.UserDN}})(uniqueMember={{.UserDN}}))`, which is compatible with several common directory schemas. To support nested group resolution for Active Directory, instead use the following query: `(&(objectClass=group)(member:1.2.840.113556.1.4.1941:={{.UserDN}}))`. @@ -113,6 +128,7 @@ distinguished name defined for `BindDN` is used for the group search. Otherwise, the authenticating user is used to perform the group search. ### User Attributes + Using configuration you can choose to optionally include an authenticated user's DN and entry attributes in the results of an authentication request. @@ -125,3 +141,7 @@ user's DN and entry attributes in the results of an authentication request. defines a set of user attributes to be excluded when an authenticating user's attributes are included in an AuthResult.Note: the default password attribute for both openLDAP (userPassword) and AD (unicodePwd) will always be excluded. + +### Other + +* `MaximumPageSize` (int, optional) - If set to a value greater than 0, the LDAP backend will use the LDAP server's paged search control to request pages of up to the given size. This can be used to avoid hitting the LDAP server's maximum result size limit. Otherwise, the LDAP backend will not use the paged search control. diff --git a/ldap/client.go b/ldap/client.go index 6b76aaf0..b57ebb8e 100644 --- a/ldap/client.go +++ b/ldap/client.go @@ -15,6 +15,7 @@ import ( "net" "net/url" "strings" + "sync" "text/template" "time" @@ -327,9 +328,10 @@ func (c *Client) getUserAttributes(userDN string) ([]Attribute, error) { } result, err := c.conn.Search(&ldap.SearchRequest{ - BaseDN: userDN, - Scope: ldap.ScopeBaseObject, - Filter: "(objectClass=*)", + BaseDN: userDN, + Scope: ldap.ScopeBaseObject, + DerefAliases: derefAliasMap[c.conf.DerefAliases], + Filter: "(objectClass=*)", }) switch { case err != nil: @@ -438,9 +440,10 @@ func (c *Client) tokenGroupsSearch(userDN string) ([]*ldap.Entry, []Warning, err return nil, warnings, fmt.Errorf("%s: missing user dn: %w", op, ErrInvalidParameter) } result, err := c.conn.Search(&ldap.SearchRequest{ - BaseDN: userDN, - Scope: ldap.ScopeBaseObject, - Filter: "(objectClass=*)", + BaseDN: userDN, + Scope: ldap.ScopeBaseObject, + DerefAliases: derefAliasMap[c.conf.DerefAliases], + Filter: "(objectClass=*)", Attributes: []string{ "tokenGroups", }, @@ -456,34 +459,63 @@ func (c *Client) tokenGroupsSearch(userDN string) ([]*ldap.Entry, []Warning, err userEntry := result.Entries[0] groupAttrValues := userEntry.GetRawAttributeValues("tokenGroups") - groupEntries := make([]*ldap.Entry, 0, len(groupAttrValues)) - for _, sidBytes := range groupAttrValues { - sidString, err := sidBytesToString(sidBytes) - if err != nil { - warnings = append(warnings, fmtWarning("%s: unable to read sid: %s", op, err.Error())) - continue - } - groupResult, err := c.conn.Search(&ldap.SearchRequest{ - BaseDN: fmt.Sprintf("", sidString), - Scope: ldap.ScopeBaseObject, - Filter: "(objectClass=*)", - Attributes: []string{ - "1.1", // RFC no attributes - }, - SizeLimit: 1, - }) - if err != nil { - warnings = append(warnings, fmtWarning("%s: unable to read the group sid (baseDN: %q / filter: %q): %s", op, fmt.Sprintf("", sidString), "(objectClass=*)", sidString)) - continue + { + // we're using worker pool to make looking up token groups more + // performant. token groups have to be looked up individually, so if a + // user is a member of MANY groups it can be helpful to do these lookups + // concurrently vs serially. This is based on benchmarks and a + // subsequent implementation within vault's codebase for looking up token + // groups. See: https://github.com/hashicorp/vault/pull/22659 + const maxWorkers = 10 + var wg sync.WaitGroup + var lock sync.Mutex + taskChan := make(chan string) // intentionally an unbuffered chan so we can iterate (range) over it before it's closed. + for i := 0; i < maxWorkers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for sidString := range taskChan { + groupResult, err := c.conn.Search(&ldap.SearchRequest{ + BaseDN: fmt.Sprintf("", sidString), + Scope: ldap.ScopeBaseObject, + DerefAliases: derefAliasMap[c.conf.DerefAliases], + Filter: "(objectClass=*)", + Attributes: []string{ + "1.1", // RFC no attributes + }, + SizeLimit: 1, + }) + if err != nil { + warnings = append(warnings, fmtWarning("%s: unable to read the group sid (baseDN: %q / filter: %q): %s", op, fmt.Sprintf("", sidString), "(objectClass=*)", sidString)) + continue + } + if len(groupResult.Entries) == 0 { + warnings = append(warnings, fmtWarning("%s: unable to find the group sid (baseDN: %q / filter: %q): %s", op, fmt.Sprintf("", sidString), "(objectClass=*)", sidString)) + continue + } + lock.Lock() + groupEntries = append(groupEntries, groupResult.Entries[0]) + lock.Unlock() + } + }() } - if len(groupResult.Entries) == 0 { - warnings = append(warnings, fmtWarning("%s: unable to find the group sid (baseDN: %q / filter: %q): %s", op, fmt.Sprintf("", sidString), "(objectClass=*)", sidString)) - continue + for _, sidBytes := range groupAttrValues { + sidString, err := sidBytesToString(sidBytes) + if err != nil { + warnings = append(warnings, fmtWarning("%s: unable to read sid: %s", op, err.Error())) + continue + } + taskChan <- sidString } + // closing the taskChan will allow the workers to start iterating + // (range) - this unblocks them + close(taskChan) - groupEntries = append(groupEntries, groupResult.Entries[0]) + // wait for all the workers to finish up the token group lookups and + // adding all the groups to the slice of group entries + wg.Wait() } return groupEntries, warnings, nil @@ -525,15 +557,23 @@ func (c *Client) filterGroupsSearch(userDN string, username string) ([]*ldap.Ent return nil, warnings, fmt.Errorf("%s: LDAP search failed due to template parsing error: %w", op, err) } - result, err := c.conn.Search(&ldap.SearchRequest{ - BaseDN: c.conf.GroupDN, - Scope: ldap.ScopeWholeSubtree, - Filter: renderedQuery.String(), + var result *ldap.SearchResult + req := ldap.SearchRequest{ + BaseDN: c.conf.GroupDN, + Scope: ldap.ScopeWholeSubtree, + DerefAliases: derefAliasMap[c.conf.DerefAliases], + Filter: renderedQuery.String(), Attributes: []string{ c.conf.GroupAttr, }, SizeLimit: math.MaxInt32, - }) + } + switch { + case c.conf.MaximumPageSize > 0: + result, err = c.conn.SearchWithPaging(&req, uint32(c.conf.MaximumPageSize)) + default: + result, err = c.conn.Search(&req) + } if err != nil { switch { case ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject): @@ -631,10 +671,11 @@ func (c *Client) getUserBindDN(username string) (string, error) { } result, err := c.conn.Search(&ldap.SearchRequest{ - BaseDN: c.conf.UserDN, - Scope: ldap.ScopeWholeSubtree, - Filter: filter, - SizeLimit: math.MaxInt32, + BaseDN: c.conf.UserDN, + Scope: ldap.ScopeWholeSubtree, + DerefAliases: derefAliasMap[c.conf.DerefAliases], + Filter: filter, + SizeLimit: math.MaxInt32, }) if err != nil { return "", fmt.Errorf("%s: LDAP search for binddn failed using (baseDN: %q / filter: %q): %w", op, c.conf.UserDN, filter, err) @@ -669,10 +710,11 @@ func (c *Client) getUserDN(bindDN, username string) (string, error) { // Find the distinguished name for the user if userPrincipalName used for login filter := fmt.Sprintf("(userPrincipalName=%s@%s)", escapeValue(username), c.conf.UPNDomain) result, err := c.conn.Search(&ldap.SearchRequest{ - BaseDN: c.conf.UserDN, - Scope: ldap.ScopeWholeSubtree, - Filter: filter, - SizeLimit: math.MaxInt32, + BaseDN: c.conf.UserDN, + Scope: ldap.ScopeWholeSubtree, + DerefAliases: derefAliasMap[c.conf.DerefAliases], + Filter: filter, + SizeLimit: math.MaxInt32, }) if err != nil { return userDN, fmt.Errorf("%s: LDAP search failed for detecting user (baseDN: %q / filter: %q): %w", op, c.conf.UserDN, filter, err) diff --git a/ldap/client_test.go b/ldap/client_test.go index 09424c12..e4007b77 100644 --- a/ldap/client_test.go +++ b/ldap/client_test.go @@ -79,7 +79,6 @@ func TestClient_renderUserSearchFilter(t *testing.T) { assert.Equal(tc.want, f) }) } - } func TestClient_NewClient(t *testing.T) { @@ -93,6 +92,7 @@ func TestClient_NewClient(t *testing.T) { tests := []struct { name string conf *ClientConfig + want *Client wantErr bool wantErrIs error wantErrContains string @@ -135,6 +135,17 @@ func TestClient_NewClient(t *testing.T) { conf: &ClientConfig{ TLSMaxVersion: "tls13", }, + want: &Client{ + conf: &ClientConfig{ + URLs: []string{"ldaps://127.0.0.1:686"}, + DerefAliases: "never", + GroupFilter: "(|(memberUid={{.Username}})(member={{.UserDN}})(uniqueMember={{.UserDN}}))", + GroupAttr: "cn", + UserAttr: "cn", + TLSMinVersion: "tls12", + TLSMaxVersion: "tls13", + }, + }, }, { name: "invalid-tls-max", @@ -183,9 +194,66 @@ func TestClient_NewClient(t *testing.T) { ClientTLSKey: td.ClientKey(), ClientTLSCert: td.ClientCert(), }, + want: &Client{ + conf: &ClientConfig{ + URLs: []string{"localhost"}, + DerefAliases: "never", + GroupFilter: "(|(memberUid={{.Username}})(member={{.UserDN}})(uniqueMember={{.UserDN}}))", + GroupAttr: "cn", + UserAttr: "cn", + TLSMinVersion: "tls12", + TLSMaxVersion: "tls13", + Certificates: []string{td.Cert()}, + ClientTLSKey: td.ClientKey(), + ClientTLSCert: td.ClientCert(), + }, + }, + }, + { + name: "invalid-deref-aliases", + conf: &ClientConfig{ + URLs: []string{"localhost"}, + DerefAliases: "invalid", + }, + wantErr: true, + wantErrContains: `invalid dereference_aliases "invalid"`, + }, + { + name: "default-deref-aliases", + conf: &ClientConfig{ + URLs: []string{"localhost"}, + }, + want: &Client{ + conf: &ClientConfig{ + URLs: []string{"localhost"}, + DerefAliases: "never", + GroupFilter: "(|(memberUid={{.Username}})(member={{.UserDN}})(uniqueMember={{.UserDN}}))", + GroupAttr: "cn", + UserAttr: "cn", + TLSMinVersion: "tls12", + TLSMaxVersion: "tls13", + }, + }, + }, + { + name: "valid-deref-aliases", + conf: &ClientConfig{ + URLs: []string{"localhost"}, + DerefAliases: "always", + }, + want: &Client{ + conf: &ClientConfig{ + URLs: []string{"localhost"}, + DerefAliases: "always", + GroupFilter: "(|(memberUid={{.Username}})(member={{.UserDN}})(uniqueMember={{.UserDN}}))", + GroupAttr: "cn", + UserAttr: "cn", + TLSMinVersion: "tls12", + TLSMaxVersion: "tls13", + }, + }, }, } - for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { assert, require := assert.New(t), require.New(t) @@ -209,8 +277,9 @@ func TestClient_NewClient(t *testing.T) { assert.Equal(DefaultGroupFilter, c.conf.GroupFilter) assert.Equal(DefaultTLSMinVersion, c.conf.TLSMinVersion) assert.Equal(DefaultTLSMaxVersion, c.conf.TLSMaxVersion) - } else { - + } + if tc.want != nil { + assert.Equal(tc.want, c) } }) } diff --git a/ldap/config.go b/ldap/config.go index 1038f686..97bddcad 100644 --- a/ldap/config.go +++ b/ldap/config.go @@ -8,10 +8,33 @@ import ( "crypto/x509" "encoding/pem" "fmt" + "strings" + "github.com/go-ldap/ldap/v3" "github.com/hashicorp/go-secure-stdlib/tlsutil" ) +var derefAliasMap = map[string]int{ + "never": ldap.NeverDerefAliases, + "finding": ldap.DerefFindingBaseObj, + "searching": ldap.DerefInSearching, + "always": ldap.DerefAlways, +} + +func validateDerefAlias(deref string) (string, error) { + const op = "ldap.validateDerefAlias" + lowerDeref := strings.ToLower(deref) + _, found := derefAliasMap[lowerDeref] + switch { + case found: + return lowerDeref, nil + case deref == "": + return DefaultDerefAliases, nil + default: + return "", fmt.Errorf("%s: invalid dereference_aliases %q: %w", op, deref, ErrInvalidParameter) + } +} + const ( // DefaultTimeout is the timeout value used for both dialing and requests to // the LDAP server @@ -44,6 +67,9 @@ const ( // DefaultADUserPasswordAttribute defines the attribute name for the // AD default password attribute which will always be excluded DefaultADUserPasswordAttribute = "unicodePwd" + + // DefaultDerefAliases defines the default for dereferencing aliases + DefaultDerefAliases = "never" ) type ClientConfig struct { @@ -178,6 +204,19 @@ type ClientConfig struct { // group membership be included an authentication AuthResult. IncludeUserGroups bool + // MaximumPageSize optionally specifies a maximum ldap search result size to + // use when retrieving the authenticated user's group memberships. This can + // be used to avoid reaching the LDAP server's max result size. + MaximumPageSize int `json:"max_page_size"` + + // DerefAliases will control how aliases are dereferenced when + // performing the search. Possible values are: never, finding, searching, + // and always. If unset, a default of "never" is used. When set to + // "finding", it will only dereference aliases during name resolution of the + // base. When set to "searching", it will dereference aliases after name + // resolution. + DerefAliases string `json:"dereference_aliases"` + // DeprecatedVaultPre111GroupCNBehavior: if true, group searching reverts to // the pre 1.1.1 Vault behavior. // see: https://www.vaultproject.io/docs/upgrading/upgrade-to-1.1.1 @@ -221,6 +260,11 @@ func (c *ClientConfig) validate() error { return fmt.Errorf("%s: failed to parse client X509 key pair: %w", op, err) } } + var err error + c.DerefAliases, err = validateDerefAlias(c.DerefAliases) + if err != nil { + return fmt.Errorf("%s: %w", op, err) + } return nil } diff --git a/ldap/conn_test.go b/ldap/conn_test.go index 703e71f7..e22be8a2 100644 --- a/ldap/conn_test.go +++ b/ldap/conn_test.go @@ -7,24 +7,24 @@ import ( "testing" ) -func Test_EscapeValue(t *testing.T) { - testcases := map[string]string{ - "#test": "\\#test", - "test,hello": "test\\,hello", - "test,hel+lo": "test\\,hel\\+lo", - "test\\hello": "test\\\\hello", - " test ": "\\ test \\ ", - "": "", - `\`: `\\`, - "trailing\000": `trailing\00`, - "mid\000dle": `mid\00dle`, - "\000": `\00`, - "multiple\000\000": `multiple\00\00`, - "backlash-before-null\\\000": `backlash-before-null\\\00`, - "trailing\\": `trailing\\`, - "double-escaping\\>": `double-escaping\\\>`, - } +var testcases = map[string]string{ + "#test": "\\#test", + "test,hello": "test\\,hello", + "test,hel+lo": "test\\,hel\\+lo", + "test\\hello": "test\\\\hello", + " test ": "\\ test \\ ", + "": "", + `\`: `\\`, + "trailing\000": `trailing\00`, + "mid\000dle": `mid\00dle`, + "\000": `\00`, + "multiple\000\000": `multiple\00\00`, + "backlash-before-null\\\000": `backlash-before-null\\\00`, + "trailing\\": `trailing\\`, + "double-escaping\\>": `double-escaping\\\>`, +} +func Test_EscapeValue(t *testing.T) { for test, answer := range testcases { res := escapeValue(test) if res != answer { @@ -32,3 +32,13 @@ func Test_EscapeValue(t *testing.T) { } } } + +// Fuzz_EscapeValue is only focused on finding panics +func Fuzz_EscapeValue(f *testing.F) { + for tc := range testcases { + f.Add(tc) + } + f.Fuzz(func(t *testing.T, s string) { + _ = escapeValue(s) + }) +} diff --git a/ldap/examples/cli/go.mod b/ldap/examples/cli/go.mod index 378098d3..c1dc165d 100644 --- a/ldap/examples/cli/go.mod +++ b/ldap/examples/cli/go.mod @@ -28,8 +28,8 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect github.com/stretchr/testify v1.8.1 // indirect - golang.org/x/crypto v0.6.0 // indirect - golang.org/x/sys v0.5.0 // indirect - golang.org/x/term v0.5.0 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/term v0.15.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/ldap/examples/cli/go.sum b/ldap/examples/cli/go.sum index 41999b4e..1059c257 100644 --- a/ldap/examples/cli/go.sum +++ b/ldap/examples/cli/go.sum @@ -73,8 +73,8 @@ github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKs github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc= -golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -87,11 +87,11 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/ldap/go.mod b/ldap/go.mod index 310c7e1c..f5dacc5f 100644 --- a/ldap/go.mod +++ b/ldap/go.mod @@ -3,7 +3,7 @@ module github.com/hashicorp/cap/ldap go 1.20 require ( - github.com/go-ldap/ldap/v3 v3.4.5 + github.com/go-ldap/ldap/v3 v3.4.6 github.com/hashicorp/go-hclog v1.4.0 github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 @@ -17,7 +17,8 @@ require ( github.com/cenkalti/backoff v2.2.1+incompatible // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/fatih/color v1.14.1 // indirect - github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect + github.com/google/uuid v1.3.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-secure-stdlib/parseutil v0.1.7 // indirect github.com/hashicorp/go-sockaddr v1.0.2 // indirect @@ -26,7 +27,7 @@ require ( github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect - golang.org/x/crypto v0.11.0 // indirect - golang.org/x/sys v0.10.0 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/sys v0.15.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/ldap/go.sum b/ldap/go.sum index 52d3bffa..3d151538 100644 --- a/ldap/go.sum +++ b/ldap/go.sum @@ -13,10 +13,12 @@ github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5Kwzbycv github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w= github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg= -github.com/go-asn1-ber/asn1-ber v1.5.4 h1:vXT6d/FNDiELJnLb6hGNa309LMsrCoYFvpwHDF0+Y1A= -github.com/go-asn1-ber/asn1-ber v1.5.4/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= -github.com/go-ldap/ldap/v3 v3.4.5 h1:ekEKmaDrpvR2yf5Nc/DClsGG9lAmdDixe44mLzlW5r8= -github.com/go-ldap/ldap/v3 v3.4.5/go.mod h1:bMGIq3AGbytbaMwf8wdv5Phdxz0FWHTIYMSzyrYgnQs= +github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA= +github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-ldap/ldap/v3 v3.4.6 h1:ert95MdbiG7aWo/oPYp9btL3KJlMPKnP58r09rI8T+A= +github.com/go-ldap/ldap/v3 v3.4.6/go.mod h1:IGMQANNtxpsOzj7uUAMjpGBaOVTC4DYyIy8VsTdxmtc= +github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= +github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -71,16 +73,16 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= -golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= -golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -97,18 +99,21 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= -golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/oidc/access_token.go b/oidc/access_token.go index 93759836..bb017036 100644 --- a/oidc/access_token.go +++ b/oidc/access_token.go @@ -5,7 +5,7 @@ package oidc import "encoding/json" -// AccessToken is an oauth access_token. +// AccessToken is an oauth access_token. type AccessToken string // RedactedAccessToken is the redacted string or json for an oauth access_token. diff --git a/oidc/callback/authcode_test.go b/oidc/callback/authcode_test.go index f5805ee7..e18afb97 100644 --- a/oidc/callback/authcode_test.go +++ b/oidc/callback/authcode_test.go @@ -182,7 +182,6 @@ func Test_AuthCodeResponses(t *testing.T) { return } assert.Equal("login successful", string(contents)) - }) } } diff --git a/oidc/config_test.go b/oidc/config_test.go index 43f37e54..4dd3b008 100644 --- a/oidc/config_test.go +++ b/oidc/config_test.go @@ -852,7 +852,6 @@ func TestConfig_Hash(t *testing.T) { default: assert.NotEqual(got1, got2) } - }) } } diff --git a/oidc/docs.go b/oidc/docs.go index e7edca09..d768105b 100644 --- a/oidc/docs.go +++ b/oidc/docs.go @@ -5,7 +5,6 @@ oidc is a package for writing clients that integrate with OIDC Providers using OIDC flows. - Primary types provided by the package: * Request: represents one OIDC authentication flow for a user. It contains the @@ -26,13 +25,13 @@ signing algorithms, additional scopes requested, etc) capabilities like: generating an auth URL, exchanging codes for tokens, verifying tokens, making user info requests, etc. -The oidc.callback package +# The oidc.callback package The callback package includes handlers (http.HandlerFunc) which can be used for the callback leg an OIDC flow. Callback handlers for both the authorization code flow (with optional PKCE) and the implicit flow are provided. -Example apps +# Example apps Complete concise example solutions: @@ -41,6 +40,5 @@ https://github.com/hashicorp/cap/tree/main/oidc/examples/cli/ * OIDC authentication SPA: https://github.com/hashicorp/cap/tree/main/oidc/examples/spa/ - */ package oidc diff --git a/oidc/examples/spa/request_cache.go b/oidc/examples/spa/request_cache.go index eb582341..4c2169e6 100644 --- a/oidc/examples/spa/request_cache.go +++ b/oidc/examples/spa/request_cache.go @@ -25,7 +25,6 @@ func newRequestCache() *requestCache { return &requestCache{ c: map[string]extendedRequest{}, } - } // Read implements the callback.StateReader interface and will delete the state @@ -63,7 +62,6 @@ func (rc *requestCache) SetToken(id string, t oidc.Token) error { return nil } return fmt.Errorf("%s: %s not found", op, id) - } func (rc *requestCache) Delete(id string) { diff --git a/oidc/internal/base62/base62.go b/oidc/internal/base62/base62.go index 9f8c20d9..348a0405 100644 --- a/oidc/internal/base62/base62.go +++ b/oidc/internal/base62/base62.go @@ -12,8 +12,10 @@ import ( uuid "github.com/hashicorp/go-uuid" ) -const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" -const csLen = byte(len(charset)) +const ( + charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + csLen = byte(len(charset)) +) // Random generates a random string using base-62 characters. // Resulting entropy is ~5.95 bits/character. diff --git a/oidc/options_test.go b/oidc/options_test.go index 7df8f0b6..3308fab3 100644 --- a/oidc/options_test.go +++ b/oidc/options_test.go @@ -33,7 +33,6 @@ func Test_WithNow(t *testing.T) { testOpts := tokenDefaults() testOpts.withNowFunc = testNow testAssertEqualFunc(t, opts.withNowFunc, testNow, "now = %p,want %p", opts.withNowFunc, testNow) - }) t.Run("reqOptions", func(t *testing.T) { opts := getReqOpts(WithNow(testNow)) diff --git a/oidc/pkce_verifier.go b/oidc/pkce_verifier.go index aa31b534..04d3490a 100644 --- a/oidc/pkce_verifier.go +++ b/oidc/pkce_verifier.go @@ -26,7 +26,6 @@ const ( // // See: https://tools.ietf.org/html/rfc7636#section-4.1 type CodeVerifier interface { - // Verifier returns the code verifier (see: // https://tools.ietf.org/html/rfc7636#section-4.1) Verifier() string diff --git a/oidc/provider.go b/oidc/provider.go index 6f559d57..0917b92b 100644 --- a/oidc/provider.go +++ b/oidc/provider.go @@ -295,7 +295,7 @@ func (p *Provider) Exchange(ctx context.Context, oidcRequest Request, authorizat } // Add the "openid" scope, which is a required scope for oidc flows scopes = append([]string{oidc.ScopeOpenID}, scopes...) - var oauth2Config = oauth2.Config{ + oauth2Config := oauth2.Config{ ClientID: p.config.ClientID, ClientSecret: string(p.config.ClientSecret), RedirectURL: oidcRequest.RedirectURL(), @@ -664,7 +664,6 @@ func (p *Provider) HTTPClientContext(ctx context.Context) (context.Context, erro c, err := p.HTTPClient() if err != nil { return nil, fmt.Errorf("%s: %w", op, err) - } // simple to implement as a wrapper for the coreos package return oidc.ClientContext(ctx, c), nil diff --git a/oidc/request_test.go b/oidc/request_test.go index 291391f9..1119b87e 100644 --- a/oidc/request_test.go +++ b/oidc/request_test.go @@ -108,7 +108,6 @@ func TestRequest_IsExpired(t *testing.T) { require.NoError(err) assert.True(oidcRequest.IsExpired()) }) - } func Test_WithImplicit(t *testing.T) { diff --git a/oidc/testing_provider.go b/oidc/testing_provider.go index 086aa1a6..a8c7e5c3 100644 --- a/oidc/testing_provider.go +++ b/oidc/testing_provider.go @@ -56,88 +56,91 @@ var ( // Once you've started a TestProvider http server with StartTestProvider(...), // the following test endpoints are supported: // -// * GET /.well-known/openid-configuration OIDC Discovery +// - GET /.well-known/openid-configuration OIDC Discovery // -// * GET or POST /authorize OIDC authorization supporting both -// the authorization code flow (with -// optional PKCE) and the implicit -// flow with form_post. +// - GET or POST /authorize OIDC authorization supporting both +// the authorization code flow (with +// optional PKCE) and the implicit +// flow with form_post. // -// * POST /token OIDC token +// - POST /token OIDC token // -// * GET /userinfo OAuth UserInfo +// - GET /userinfo OAuth UserInfo // -// * GET /.well-known/jwks.json JWKs used to verify issued JWT tokens +// - GET /.well-known/jwks.json JWKs used to verify issued JWT tokens // -// Making requests to these endpoints are facilitated by -// * TestProvider.HTTPClient which returns an http.Client for making requests. -// * TestProvider.CACert which the pem-encoded CA certificate used by the HTTPS server. +// Making requests to these endpoints are facilitated by +// +// - TestProvider.HTTPClient which returns an http.Client for making requests. +// +// - TestProvider.CACert which the pem-encoded CA certificate used by the HTTPS server. // // Runtime Configuration: -// * Issuer: Addr() returns the the current base URL for the test provider's -// running webserver, which can be used as an OIDC Issuer for discovery and -// is also used for the iss claim when issuing JWTs. // -// * Relying Party ClientID/ClientSecret: SetClientCreds(...) updates the -// creds and they are empty by default. +// - Issuer: Addr() returns the the current base URL for the test provider's +// running web server, which can be used as an OIDC Issuer for discovery and +// is also used for the iss claim when issuing JWTs. +// +// - Relying Party ClientID/ClientSecret: SetClientCreds(...) updates the +// creds and they are empty by default. // -// * Now: SetNowFunc(...) updates the provider's "now" function and time.Now -// is the default. +// - Now: SetNowFunc(...) updates the provider's "now" function and time.Now +// is the default. // -// * Subject: SetExpectedSubject(sub string) configures the expected subject for -// any JWTs issued by the provider (the default is "alice@example.com") +// - Subject: SetExpectedSubject(sub string) configures the expected subject for +// any JWTs issued by the provider (the default is "alice@example.com") // -// * Subject Passwords: SetSubjectInfo(...) configures a subject/password -// dictionary. If configured, then an interactive Login form is presented by -// the /authorize endpoint and the TestProvider becomes an interactive test -// provider using the provided subject/password dictionary. +// - Subject Passwords: SetSubjectInfo(...) configures a subject/password +// dictionary. If configured, then an interactive Login form is presented by +// the /authorize endpoint and the TestProvider becomes an interactive test +// provider using the provided subject/password dictionary. // -// * Expiry: SetExpectedExpiry(exp time.Duration) updates the expiry and -// now + 5 * time.Second is the default. +// - Expiry: SetExpectedExpiry(exp time.Duration) updates the expiry and +// now + 5 * time.Second is the default. // -// * Signing keys: SetSigningKeys(...) updates the keys and a ECDSA P-256 pair -// of priv/pub keys are the default with a signing algorithm of ES256 +// - Signing keys: SetSigningKeys(...) updates the keys and a ECDSA P-256 pair +// of priv/pub keys are the default with a signing algorithm of ES256 // -// * Authorization Code: SetExpectedAuthCode(...) updates the auth code -// required by the /authorize endpoint and the code is empty by default. +// - Authorization Code: SetExpectedAuthCode(...) updates the auth code +// required by the /authorize endpoint and the code is empty by default. // -// * Authorization Nonce: SetExpectedAuthNonce(...) updates the nonce required -// by the /authorize endpont and the nonce is empty by default. +// - Authorization Nonce: SetExpectedAuthNonce(...) updates the nonce required +// by the /authorize endpoint and the nonce is empty by default. // -// * Allowed RedirectURIs: SetAllowedRedirectURIs(...) updates the allowed -// redirect URIs and "https://example.com" is the default. +// - Allowed RedirectURIs: SetAllowedRedirectURIs(...) updates the allowed +// redirect URIs and "https://example.com" is the default. // -// * Custom Claims: SetCustomClaims(...) updates custom claims added to JWTs issued -// and the custom claims are empty by default. +// - Custom Claims: SetCustomClaims(...) updates custom claims added to JWTs issued +// and the custom claims are empty by default. // -// * Audiences: SetCustomAudience(...) updates the audience claim of JWTs issued -// and the ClientID is the default. +// - Audiences: SetCustomAudience(...) updates the audience claim of JWTs issued +// and the ClientID is the default. // -// * Authentication Time (auth_time): SetOmitAuthTimeClaim(...) allows you to -// turn off/on the inclusion of an auth_time claim in issued JWTs and the claim -// is included by default. +// - Authentication Time (auth_time): SetOmitAuthTimeClaim(...) allows you to +// turn off/on the inclusion of an auth_time claim in issued JWTs and the claim +// is included by default. // -// * Issuing id_tokens: SetOmitIDTokens(...) allows you to turn off/on the issuing of -// id_tokens from the /token endpoint. id_tokens are issued by default. +// - Issuing id_tokens: SetOmitIDTokens(...) allows you to turn off/on the issuing of +// id_tokens from the /token endpoint. id_tokens are issued by default. // -// * Issuing access_tokens: SetOmitAccessTokens(...) allows you to turn off/on -// the issuing of access_tokens from the /token endpoint. access_tokens are issued -// by default. +// - Issuing access_tokens: SetOmitAccessTokens(...) allows you to turn off/on +// the issuing of access_tokens from the /token endpoint. access_tokens are issued +// by default. // -// * Authorization State: SetExpectedState sets the value for the state parameter -// returned from the /authorized endpoint +// - Authorization State: SetExpectedState sets the value for the state parameter +// returned from the /authorized endpoint // -// * Token Responses: SetDisableToken disables the /token endpoint, causing -// it to return a 401 http status. +// - Token Responses: SetDisableToken disables the /token endpoint, causing +// it to return a 401 http status. // -// * Implicit Flow Responses: SetDisableImplicit disables implicit flow responses, -// causing them to return a 401 http status. +// - Implicit Flow Responses: SetDisableImplicit disables implicit flow responses, +// causing them to return a 401 http status. // -// * PKCE verifier: SetPKCEVerifier(oidc.CodeVerifier) sets the PKCE code_verifier -// and PKCEVerifier() returns the current verifier. +// - PKCE verifier: SetPKCEVerifier(oidc.CodeVerifier) sets the PKCE code_verifier +// and PKCEVerifier() returns the current verifier. // -// * UserInfo: SetUserInfoReply sets the UserInfo endpoint response and -// UserInfoReply() returns the current response. +// - UserInfo: SetUserInfoReply sets the UserInfo endpoint response and +// UserInfoReply() returns the current response. type TestProvider struct { httpServer *httptest.Server caCert string @@ -351,7 +354,6 @@ func getTestProviderOpts(t TestingT, opt ...Option) testProviderOptions { } // withTestSubject provides the option to provide a subject -// func withTestSubject(s string) Option { return func(o interface{}) { if o, ok := o.(*testProviderOptions); ok { @@ -361,7 +363,6 @@ func withTestSubject(s string) Option { } // withTestNonce provides the option to provide a nonce -// func withTestNonce(n string) Option { return func(o interface{}) { if o, ok := o.(*testProviderOptions); ok { diff --git a/saml/README.md b/saml/README.md new file mode 100644 index 00000000..6ff4c298 --- /dev/null +++ b/saml/README.md @@ -0,0 +1,47 @@ + +# [`saml package`](./saml) + +[![Go Reference](https://pkg.go.dev/badge/github.com/hashicorp/cap/saml.svg)](https://pkg.go.dev/github.com/hashicorp/cap/saml) + +A package for writing clients that integrate with SAML Providers. + +The SAML library orients mainly on the implementation profile for +[federation interoperability](https://kantarainitiative.github.io/SAMLprofiles/fedinterop.html) +(also known as interoperable SAML), a set of software conformance requirements +intended to facilitate interoperability within the context of full mesh identity +federations. It supports the Web Browser SSO profile with HTTP-Post and +HTTP-Redirect as supported service bindings. The default SAML settings follow +the requirements of the interoperable SAML +[deployment profile](https://kantarainitiative.github.io/SAMLprofiles/saml2int.html#_service_provider_requirements). + +## Example usage + +```go + // Create a new saml config providing the necessary provider information: + cfg, err := saml.NewConfig(, , , options...) + // handle error + + // Use the config to create the service provider: + sp, err := saml.NewServiceProvider(cfg) + // handle error + + // With the service provider you can create saml authentication requests: + + // Generate a saml auth request with HTTP Post-Binding + template, err := sp.AuthRequestPost("relay state", options...) + // handle error + + // Generate a saml auth request with HTTP Request-Binding + redirectURL, err := sp.AuthRequestRedirect("relay state", options...) + // handle error + + // Parsing a SAML response: + r.ParseForm() + samlResp := r.PostForm.Get("SAMLResponse") + + response, err := sp.ParseResponse(samlResp, "Response ID", options...) + // handle error +``` + +You can find the full demo code in the [`saml/demo`](./saml/demo/main.go) +package. diff --git a/saml/authn_request.go b/saml/authn_request.go new file mode 100644 index 00000000..cd309282 --- /dev/null +++ b/saml/authn_request.go @@ -0,0 +1,399 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package saml + +import ( + "bytes" + "compress/flate" + "encoding/base64" + "encoding/xml" + "fmt" + "net/http" + "net/url" + "strings" + "text/template" + "time" + + "github.com/jonboulle/clockwork" + + "github.com/hashicorp/cap/saml/models/core" +) + +const ( + // postBindingScriptSha256 is a base64 encoded sha256 hash generated from the javascript within the script tag in ./authn_request.gohtml. + // The hash is set in the Content-Security-Policy header when using the SAML HTTP POST-Binding Authentication Request. + // You can read more about the header and how the hash is generated here: https://content-security-policy.com/hash/ + // As the POST-Binding script is static, this value is static as well and shouldn't change. + postBindingScriptSha256 = "sha256-T8Q9GZiIVtYoNIdF6UW5hDNgJudFDijQM/usO+xUkes=" +) + +type authnRequestOptions struct { + clock clockwork.Clock + allowCreate bool + nameIDFormat core.NameIDFormat + forceAuthn bool + protocolBinding core.ServiceBinding + authnContextClassRefs []string + indent int + assertionConsumerServiceURL string +} + +func authnRequestOptionsDefault() authnRequestOptions { + return authnRequestOptions{ + allowCreate: false, + clock: clockwork.NewRealClock(), + nameIDFormat: core.NameIDFormat(""), + forceAuthn: false, + protocolBinding: core.ServiceBindingHTTPPost, + } +} + +func getAuthnRequestOptions(opt ...Option) authnRequestOptions { + opts := authnRequestOptionsDefault() + ApplyOpts(&opts, opt...) + return opts +} + +// AllowCreate is a Boolean value used to indicate whether the identity provider is allowed, in the course +// of fulfilling the request, to create a new identifier to represent the principal. +func AllowCreate() Option { + return func(o interface{}) { + if o, ok := o.(*authnRequestOptions); ok { + o.allowCreate = true + } + } +} + +// WithNameIDFormat will set an NameIDPolicy object with the +// given NameIDFormat. It implies allowCreate=true as recommended by +// the SAML 2.0 spec, which says: +// "Requesters that do not make specific use of this (AllowCreate) attribute SHOULD generally set it to “true” +// to maximize interoperability." +// See https://www.oasis-open.org/committees/download.php/56776/sstc-saml-core-errata-2.0-wd-07.pdf +func WithNameIDFormat(f core.NameIDFormat) Option { + return func(o interface{}) { + if o, ok := o.(*authnRequestOptions); ok { + o.nameIDFormat = f + o.allowCreate = true + } + } +} + +// ForceAuthentication is a boolean value that tells the identity provider it MUST authenticate the presenter +// directly rather than rely on a previous security context. +func ForceAuthn() Option { + return func(o interface{}) { + if o, ok := o.(*authnRequestOptions); ok { + o.forceAuthn = true + } + } +} + +// WithProtocolBinding defines the ProtocolBinding to be used. It defaults to HTTP-Post. +// The ProtocolBinding is a URI reference that identifies a SAML protocol binding to be used +// when returning the message. +func WithProtocolBinding(binding core.ServiceBinding) Option { + return func(o interface{}) { + if o, ok := o.(*authnRequestOptions); ok { + o.protocolBinding = binding + } + } +} + +// WithAuthContextClassRefs defines AuthnContextClassRefs. +// An AuthContextClassRef Specifies the requirements, if any, that the requester places on the +// authentication context that applies to the responding provider's authentication of the presenter. +func WithAuthContextClassRefs(cfs []string) Option { + return func(o interface{}) { + if o, ok := o.(*authnRequestOptions); ok { + o.authnContextClassRefs = cfs + } + } +} + +// WithIndent indent the XML document when marshalling it. +func WithIndent(indent int) Option { + return func(o interface{}) { + if o, ok := o.(*authnRequestOptions); ok { + o.indent = indent + } + } +} + +// WithClock changes the clock used when generating requests. +func WithClock(clock clockwork.Clock) Option { + return func(o interface{}) { + switch opts := o.(type) { + case *authnRequestOptions: + opts.clock = clock + case *parseResponseOptions: + opts.clock = clock + case *idpMetadataOptions: + opts.clock = clock + } + } +} + +// WithAssertionConsumerServiceURL changes the Assertion Consumer Service URL +// to use in the Auth Request or during the response validation +func WithAssertionConsumerServiceURL(url string) Option { + return func(o interface{}) { + switch opts := o.(type) { + case *authnRequestOptions: + opts.assertionConsumerServiceURL = url + case *parseResponseOptions: + opts.assertionConsumerServiceURL = url + } + } +} + +// CreateAuthnRequest creates an Authentication Request object. +// The defaults follow the deployment profile for federation interoperability. +// See: 3.1.1 https://kantarainitiative.github.io/SAMLprofiles/saml2int.html#_service_provider_requirements [INT_SAML] +// +// Options: +// - WithClock +// - ForceAuthn +// - AllowCreate +// - WithIDFormat +// - WithProtocolBinding +// - WithAuthContextClassRefs +// - WithAssertionConsumerServiceURL +func (sp *ServiceProvider) CreateAuthnRequest( + id string, + binding core.ServiceBinding, + opt ...Option, +) (*core.AuthnRequest, error) { + const op = "saml.ServiceProvider.CreateAuthnRequest" + + if id == "" { + return nil, fmt.Errorf("%s: no ID provided: %w", op, ErrInvalidParameter) + } + + if binding == "" { + return nil, fmt.Errorf("%s: no binding provided: %w", op, ErrInvalidParameter) + } + + opts := getAuthnRequestOptions(opt...) + + destination, err := sp.destination(binding) + if err != nil { + return nil, fmt.Errorf( + "%s: failed to get destination for given service binding (%s): %w", + op, + binding, + err, + ) + } + + ar := &core.AuthnRequest{} + + ar.ID = id + ar.Version = core.SAMLVersion2 + ar.ProtocolBinding = opts.protocolBinding + + // [INT_SAML][SDP-SP05][SDP-SP06] + // "The message SHOULD contain an AssertionConsumerServiceURL attribute and MUST NOT contain an + // AssertionConsumerServiceIndex attribute (i.e., the desired endpoint MUST be the default, + // or identified via the AssertionConsumerServiceURL attribute)." + ar.AssertionConsumerServiceURL = sp.cfg.AssertionConsumerServiceURL + if opts.assertionConsumerServiceURL != "" { + ar.AssertionConsumerServiceURL = opts.assertionConsumerServiceURL + } + + ar.IssueInstant = opts.clock.Now().Truncate(time.Microsecond).UTC() + ar.Destination = destination + + ar.Issuer = &core.Issuer{} + ar.Issuer.Value = sp.cfg.EntityID + + // [INT_SAML][SDP-SP04] + // "The message MUST either omit the element (RECOMMENDED), + // or the element MUST contain an AllowCreate attribute of "true" and MUST NOT contain a Format attribute." + if opts.allowCreate || opts.nameIDFormat != "" { + ar.NameIDPolicy = &core.NameIDPolicy{ + AllowCreate: opts.allowCreate, + } + + // This will only be set if the option WithNameIDFormat is set. + if opts.nameIDFormat != "" { + ar.NameIDPolicy.Format = opts.nameIDFormat + } + } + + // [INT_SAML][SDP-SP07] + // "An SP that does not require a specific value MUST NOT include a + // element in its requests. + // An SP that requires specific values MUST specify the allowable values + // in a element in its requests, with the Comparison attribute set to exact." + if len(opts.authnContextClassRefs) > 0 { + ar.RequestedAuthContext = &core.RequestedAuthnContext{ + AuthnContextClassRef: opts.authnContextClassRefs, + Comparison: core.ComparisonExact, + } + } + + ar.ForceAuthn = opts.forceAuthn + + return ar, nil +} + +// AuthnRequestPost creates an AuthRequest with HTTP-Post binding. +func (sp *ServiceProvider) AuthnRequestPost( + relayState string, opt ...Option, +) ([]byte, *core.AuthnRequest, error) { + const op = "saml.ServiceProvider.AuthnRequestPost" + + requestID, err := sp.cfg.GenerateAuthRequestID() + if err != nil { + return nil, nil, fmt.Errorf( + "%s: failed to generate authentication request ID: %w", + op, + ErrInternal, + ) + } + + authN, err := sp.CreateAuthnRequest(requestID, core.ServiceBindingHTTPPost) + if err != nil { + return nil, nil, fmt.Errorf( + "%s: failed to create authentication request: %w", + op, + ErrInternal, + ) + } + + opts := getAuthnRequestOptions(opt...) + payload, err := authN.CreateXMLDocument(opts.indent) + if err != nil { + return nil, nil, fmt.Errorf( + "%s: failed to create request XML: %w", + op, + ErrInternal, + ) + } + + b64Payload := base64.StdEncoding.EncodeToString(payload) + + tmpl := template.Must( + template.New("post-binding").Parse(postBindingTempl), + ) + + buf := bytes.Buffer{} + + if err := tmpl.Execute(&buf, map[string]string{ + "Destination": authN.Destination, + "SAMLRequest": b64Payload, + "RelayState": relayState, + }); err != nil { + return nil, nil, fmt.Errorf( + "%s: failed to execute POST binding template: %w", + op, + ErrInternal, + ) + } + + return buf.Bytes(), authN, nil +} + +// WritePostBindingRequestHeader writes recommended content headers when using the SAML HTTP POST binding. +func WritePostBindingRequestHeader(w http.ResponseWriter) error { + const op = "saml.WritePostBindingHeader" + + if w == nil { + return fmt.Errorf("%s: response writer is nil", op) + } + + w.Header(). + Add("Content-Security-Policy", fmt.Sprintf("script-src '%s'", postBindingScriptSha256)) + w.Header().Add("Content-type", "text/html") + + return nil +} + +// AuthRequestRedirect creates a SAML authentication request with HTTP redirect binding. +func (sp *ServiceProvider) AuthnRequestRedirect( + relayState string, opts ...Option, +) (*url.URL, *core.AuthnRequest, error) { + const op = "saml.ServiceProvider.AuthnRequestRedirect" + + requestID, err := sp.cfg.GenerateAuthRequestID() + if err != nil { + return nil, nil, fmt.Errorf( + "%s: failed to generate authentication request ID: %w", + op, + err, + ) + } + + authN, err := sp.CreateAuthnRequest(requestID, core.ServiceBindingHTTPRedirect, opts...) + if err != nil { + return nil, nil, fmt.Errorf( + "%s: failed to create SAML auth request: %w", + op, + err, + ) + } + + payload, err := Deflate(authN, opts...) + if err != nil { + return nil, nil, fmt.Errorf("%s: failed to deflate/compress request: %w", op, err) + } + + b64Payload := base64.StdEncoding.EncodeToString(payload) + + redirect, err := url.Parse(authN.Destination) + if err != nil { + return nil, nil, fmt.Errorf("%s: failed to parse destination URL: %w", op, err) + } + + // if sp.SignRequest { + // ctx := sp.SigningContext() + // qs.Add("SigAlg", ctx.GetSignatureMethodIdentifier()) + // var rawSignature []byte + // if rawSignature, err = ctx.SignString(signatureInputString(qs.Get("SAMLRequest"), qs.Get("RelayState"), qs.Get("SigAlg"))); err != nil { + // return "", fmt.Errorf("unable to sign query string of redirect URL: %v", err) + // } + + // // Now add base64 encoded Signature + // qs.Add("Signature", base64.StdEncoding.EncodeToString(rawSignature)) + // } + + vals := redirect.Query() + vals.Set("SAMLRequest", b64Payload) + + if relayState != "" { + vals.Set("RelayState", relayState) + } + + redirect.RawQuery = vals.Encode() + + return redirect, authN, nil +} + +// Deflate returns an AuthnRequest in the Deflate file format, applying default +// compression. +func Deflate(authn *core.AuthnRequest, opt ...Option) ([]byte, error) { + const op = "saml.Deflate" + + buf := bytes.Buffer{} + opts := getAuthnRequestOptions(opt...) + + fw, err := flate.NewWriter(&buf, flate.DefaultCompression) + if err != nil { + return nil, fmt.Errorf("%s: failed to create new flate writer: %w", op, err) + } + + encoder := xml.NewEncoder(fw) + encoder.Indent("", strings.Repeat(" ", opts.indent)) + err = encoder.Encode(authn) + if err != nil { + return nil, fmt.Errorf("%s: failed to XML encode SAML authn request: %w", op, err) + } + + if err := fw.Close(); err != nil { + return nil, fmt.Errorf("%s: failed to close flate writer: %w", op, err) + } + + return buf.Bytes(), nil +} diff --git a/saml/authn_request.gohtml b/saml/authn_request.gohtml new file mode 100644 index 00000000..914708fa --- /dev/null +++ b/saml/authn_request.gohtml @@ -0,0 +1,11 @@ + + + +
+ + + +
+ + + diff --git a/saml/authn_request_test.go b/saml/authn_request_test.go new file mode 100644 index 00000000..1a367358 --- /dev/null +++ b/saml/authn_request_test.go @@ -0,0 +1,260 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package saml_test + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/cap/saml" + "github.com/hashicorp/cap/saml/models/core" + testprovider "github.com/hashicorp/cap/saml/test" +) + +func Test_CreateAuthnRequest(t *testing.T) { + t.Parallel() + r := require.New(t) + + tp := testprovider.StartTestProvider(t) + defer tp.Close() + + cfg, err := saml.NewConfig( + "http://test.me/entity", + "http://test.me/saml/acs", + fmt.Sprintf("%s/saml/metadata", tp.ServerURL()), + ) + r.NoError(err) + + provider, err := saml.NewServiceProvider(cfg) + r.NoError(err) + + cases := []struct { + name string + id string + binding core.ServiceBinding + opts []saml.Option + expectedACS string + err string + }{ + { + name: "With service binding post", + id: "abc123", + binding: core.ServiceBindingHTTPPost, + expectedACS: "http://test.me/saml/acs", + err: "", + }, + { + name: "With service binding redirect", + id: "abc123", + binding: core.ServiceBindingHTTPRedirect, + expectedACS: "http://test.me/saml/acs", + err: "", + }, + { + name: "With service binding redirect and custom acs", + id: "abc123", + binding: core.ServiceBindingHTTPRedirect, + opts: []saml.Option{saml.WithAssertionConsumerServiceURL("http://secondary.me/saml/acs")}, + expectedACS: "http://secondary.me/saml/acs", + err: "", + }, + { + name: "When there is no ID provided", + id: "", + binding: core.ServiceBindingHTTPRedirect, + err: "saml.ServiceProvider.CreateAuthnRequest: no ID provided: invalid parameter", + }, + { + name: "When there is no binding provided", + id: "abc123", + binding: "", + err: "saml.ServiceProvider.CreateAuthnRequest: no binding provided: invalid parameter", + }, + { + name: "When there there is no destination for the given binding", + id: "abc123", + binding: core.ServiceBinding("non-existing"), + err: "saml.ServiceProvider.CreateAuthnRequest: failed to get destination for given service binding (non-existing):", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := require.New(t) + got, err := provider.CreateAuthnRequest(c.id, c.binding, c.opts...) + if c.err != "" { + r.Error(err) + r.ErrorContains(err, c.err) + return + } + r.NoError(err) + + switch c.binding { + case core.ServiceBindingHTTPPost: + loc := fmt.Sprintf("%s/saml/login/post", tp.ServerURL()) + r.Equal(loc, got.Destination) + case core.ServiceBindingHTTPRedirect: + loc := fmt.Sprintf("%s/saml/login/redirect", tp.ServerURL()) + r.Equal(loc, got.Destination) + } + + r.Equal(c.id, got.ID) + r.Equal("2.0", got.Version) + r.Equal(core.ServiceBindingHTTPPost, got.ProtocolBinding) + r.Equal(c.expectedACS, got.AssertionConsumerServiceURL) + r.Equal("http://test.me/entity", got.Issuer.Value) + r.Nil(got.NameIDPolicy) + r.Nil(got.RequestedAuthContext) + r.False(got.ForceAuthn) + }) + } +} + +func Test_CreateAuthnRequest_Options(t *testing.T) { + t.Parallel() + r := require.New(t) + + tp := testprovider.StartTestProvider(t) + defer tp.Close() + + cfg, err := saml.NewConfig( + "http://test.me/entity", + "http://test.me/saml/acs", + fmt.Sprintf("%s/saml/metadata", tp.ServerURL()), + ) + + provider, err := saml.NewServiceProvider(cfg) + r.NoError(err) + + t.Run("When option AllowCreate is set", func(t *testing.T) { + r := require.New(t) + got, err := provider.CreateAuthnRequest( + "abc123", + core.ServiceBindingHTTPPost, + saml.AllowCreate(), + ) + + r.NoError(err) + + r.NotNil(got.NameIDPolicy) + r.True(got.NameIDPolicy.AllowCreate) + }) + + t.Run("When option WithNameIDFormat is set", func(t *testing.T) { + r := require.New(t) + got, err := provider.CreateAuthnRequest( + "abc123", + core.ServiceBindingHTTPPost, + saml.WithNameIDFormat(core.NameIDFormatEmail), + ) + + r.NoError(err) + + r.NotNil(got.NameIDPolicy) + r.True(got.NameIDPolicy.AllowCreate) + r.Equal(core.NameIDFormatEmail, got.NameIDPolicy.Format) + }) + + t.Run("When option ForceAuthn is set", func(t *testing.T) { + r := require.New(t) + got, err := provider.CreateAuthnRequest( + "abc123", + core.ServiceBindingHTTPPost, + saml.ForceAuthn(), + ) + + r.NoError(err) + r.True(got.ForceAuthn) + }) + + t.Run("When option WithProtocolBinding is set", func(t *testing.T) { + r := require.New(t) + got, err := provider.CreateAuthnRequest( + "abc123", + core.ServiceBindingHTTPPost, + saml.WithProtocolBinding(core.ServiceBindingHTTPRedirect), + ) + + r.NoError(err) + r.Equal(core.ServiceBindingHTTPRedirect, got.ProtocolBinding) + }) + + t.Run("When option WithAuthnContextRefs is set", func(t *testing.T) { + r := require.New(t) + got, err := provider.CreateAuthnRequest( + "abc123", + core.ServiceBindingHTTPPost, + saml.WithAuthContextClassRefs([]string{ + "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport", + }), + ) + + r.NoError(err) + r.Contains( + got.RequestedAuthContext.AuthnContextClassRef, + "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport", + ) + r.Equal(core.ComparisonExact, got.RequestedAuthContext.Comparison) + }) + + t.Run("When more than one option is set", func(t *testing.T) { + r := require.New(t) + got, err := provider.CreateAuthnRequest( + "abc123", + core.ServiceBindingHTTPPost, + saml.ForceAuthn(), + saml.WithProtocolBinding(core.ServiceBindingHTTPRedirect), + ) + + r.NoError(err) + r.True(got.ForceAuthn) + r.Equal(core.ServiceBindingHTTPRedirect, got.ProtocolBinding) + }) + + r.NoError(err) +} + +func Test_ServiceProvider_AuthnRequestRedirect(t *testing.T) { + t.Parallel() + r := require.New(t) + + tp := testprovider.StartTestProvider(t) + defer tp.Close() + + cfg, err := saml.NewConfig( + "http://test.me/entity", + "http://test.me/saml/acs", + fmt.Sprintf("%s/saml/metadata", tp.ServerURL()), + ) + + provider, err := saml.NewServiceProvider(cfg) + r.NoError(err) + + redirectURL, _, err := provider.AuthnRequestRedirect("relayState") + r.NoError(err) + + tp.SetExpectedIssuer("http://test.me/entity") + tp.SetExpectedACSURL("http://test.me/saml/acs") + tp.SetExpectedRelayState("relayState") + + // The test server validates the request. So we don't have to do it here. + resp, err := http.Get(redirectURL.String()) + r.NoError(err) + r.NotNil(resp) + + body, err := io.ReadAll(resp.Body) + r.NoError(err) + + samlRespPostData := &testprovider.SAMLResponsePostData{} + err = json.Unmarshal(body, samlRespPostData) + r.NoError(err) + + r.Equal("http://test.me/saml/acs", samlRespPostData.Destination) + r.Equal("relayState", samlRespPostData.RelayState) +} diff --git a/saml/config.go b/saml/config.go new file mode 100644 index 00000000..4f8afb0a --- /dev/null +++ b/saml/config.go @@ -0,0 +1,274 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package saml + +import ( + "fmt" + "net/url" + "time" + + "github.com/hashicorp/go-uuid" + + "github.com/hashicorp/cap/saml/models/core" +) + +// ValidUntilFunc represents a function that sets a time until a service provider metadata +// document is valid. +type ValidUntilFunc func() time.Time + +// GenerateAuthRequestIDFunc represents a function that generates the +// SAML authentication request ID. +type GenerateAuthRequestIDFunc func() (string, error) + +// Config contains configuraiton parameters that are required for a service provider +// to successfully federate with an identity provider and execute a SAML authentication flow. +type Config struct { + // AssertionConsumerServiceURL defines the endpoint at the service provider where + // the identity provider will redirect to with its authentication response. Must be + // a valid URL. Required. + AssertionConsumerServiceURL string + + // EntityID is a globally unique identifier of the service provider. Must be a + // valid URL. Required. + EntityID string + + // MetadataURL is the endpoint an identity provider serves its metadata XML document. + // Must be a valid URL. Takes precedence over MetadataXML and MetadataParameters. + // Required if MetadataXML or MetadataParameters not set. + MetadataURL string + + // MetadataXML is the XML-formatted metadata an identity provider provides to + // configure a service provider. Takes precedence over MetadataParameters. Optional. + MetadataXML string + + // MetadataParameters are the individual parameters an identity provider provides + // to configure a service provider. Optional. + MetadataParameters *MetadataParameters + + // ValidUntil is a function that defines the time after which the service provider + // metadata document is considered invalid. Optional. + ValidUntil ValidUntilFunc + + // GenerateAuthRequestID generates an XSD:ID conforming ID. + GenerateAuthRequestID GenerateAuthRequestIDFunc +} + +// MetadataParameters are parameters that are required for SAML federation. +// This can be used when the IDP doesn't support a Metadata URL. +type MetadataParameters struct { + // Issuer is a globally unique identifier of the identity provider. + // Must be a valid URL. Required. + Issuer string + + // SingleSignOnURL is the single sign-on service URL of the identity provider. + // Must be a valid URL. Required. + SingleSignOnURL string + + // IDPCertificate is the PEM-encoded public key certificate provided by the identity + // provider. Used to verify response and assertion signatures. Required. + IDPCertificate string + + // Binding defines the binding that will be used for authentication requests. Defaults + // to HTTP-POST binding. Optional. + Binding core.ServiceBinding +} + +// Validate validates the provided metadata parameters. +func (c *MetadataParameters) Validate() error { + if c.Issuer == "" { + return fmt.Errorf("issuer not set") + } + if _, err := url.Parse(c.Issuer); err != nil { + return fmt.Errorf("provided Issuer is not a valid URL: %w", err) + } + + if c.SingleSignOnURL == "" { + return fmt.Errorf("SSO URL not set") + } + if _, err := url.Parse(c.SingleSignOnURL); err != nil { + return fmt.Errorf("provided SSO URL is not a valid URL: %w", err) + } + + if _, err := parsePEMCertificate([]byte(c.IDPCertificate)); err != nil { + return fmt.Errorf("failed to parse IDP certificate: %w", err) + } + + return nil +} + +// WithMetadataXML provides optional identity provider metadata in the form of an XML +// document that can be used to configure the service provider. +func WithMetadataXML(metadata string) Option { + return func(o interface{}) { + if o, ok := o.(*configOptions); ok { + o.withMetadataXML = metadata + } + } +} + +// WithMetadataParameters provides optional static metadata from an identity provider +// that can be used to configure the service provider. +func WithMetadataParameters(metadata MetadataParameters) Option { + return func(o interface{}) { + if o, ok := o.(*configOptions); ok { + if metadata.Binding == "" { + metadata.Binding = core.ServiceBindingHTTPPost + } + o.withMetadataParameters = &metadata + } + } +} + +// WithValidUntil provides the time after which the service provider metadata +// document is considered invalid +func WithValidUntil(validUntil ValidUntilFunc) Option { + return func(o interface{}) { + if o, ok := o.(*configOptions); ok { + o.withValidUntil = validUntil + } + } +} + +// WithGenerateAuthRequestID provides an XSD:ID conforming ID for authentication requests +func WithGenerateAuthRequestID(generateAuthRequestID GenerateAuthRequestIDFunc) Option { + return func(o interface{}) { + if o, ok := o.(*configOptions); ok { + o.withGenerateAuthRequestID = generateAuthRequestID + } + } +} + +// NewConfig creates a new configuration for a service provider. Identity provider +// metadata can be provided via the metadataURL parameter or the WithMetadataXML +// and WithMetadataParameters options. The metadataURL will always take precedence +// if options are provided. +// +// Options: +// - WithValidUntil +// - WithMetadataXML +// - WithMetadataParameters +// - WithGenerateAuthRequestID +func NewConfig(entityID, acs, metadataURL string, opt ...Option) (*Config, error) { + const op = "saml.NewConfig" + + opts := getConfigOptions(opt...) + + cfg := &Config{ + EntityID: entityID, + AssertionConsumerServiceURL: acs, + MetadataURL: metadataURL, + MetadataXML: opts.withMetadataXML, + MetadataParameters: opts.withMetadataParameters, + ValidUntil: opts.withValidUntil, + GenerateAuthRequestID: opts.withGenerateAuthRequestID, + } + + err := cfg.Validate() + if err != nil { + return nil, fmt.Errorf("%s: invalid provider config: %w", op, err) + } + + return cfg, nil +} + +// Validate validates the Config fields. +func (c *Config) Validate() error { + const op = "saml.Config.Validate" + + if c.AssertionConsumerServiceURL == "" { + return fmt.Errorf("%s: ACS URL not set: %w", op, ErrInvalidParameter) + } + if _, err := url.Parse(c.AssertionConsumerServiceURL); err != nil { + return fmt.Errorf("%s: provided ACS URL is not a valid URL: %w", op, ErrInvalidParameter) + } + + if c.EntityID == "" { + return fmt.Errorf("%s: EntityID not set: %w", op, ErrInvalidParameter) + } + if _, err := url.Parse(c.EntityID); err != nil { + return fmt.Errorf("%s: provided Entity ID is not a valid URL: %w", op, ErrInvalidParameter) + } + + if c.MetadataURL == "" && c.MetadataXML == "" && c.MetadataParameters == nil { + return fmt.Errorf("%s: One of MetadataURL, MetadataXML, or MetadataParameters "+ + "must be set: %w", op, ErrInvalidParameter) + } + if c.MetadataURL != "" { + if _, err := url.Parse(c.MetadataURL); err != nil { + return fmt.Errorf( + "%s: provided Metadata URL is not a valid URL: %w", + op, + ErrInvalidParameter, + ) + } + } + if c.MetadataXML != "" { + if _, err := parseIDPMetadata([]byte(c.MetadataXML)); err != nil { + return fmt.Errorf("%s: %s: %w", op, err.Error(), ErrInvalidParameter) + } + } + + if c.MetadataParameters != nil { + if err := c.MetadataParameters.Validate(); err != nil { + return fmt.Errorf("%s: %s: %w", op, err.Error(), ErrInvalidParameter) + } + } + + if c.GenerateAuthRequestID == nil { + return fmt.Errorf( + "%s: GenerateAuthRequestID func not provided: %w", + op, + ErrInvalidParameter, + ) + } + + return nil +} + +type configOptions struct { + withMetadataXML string + withMetadataParameters *MetadataParameters + withValidUntil ValidUntilFunc + withGenerateAuthRequestID GenerateAuthRequestIDFunc +} + +func configOptionsDefault() configOptions { + return configOptions{ + withValidUntil: defaultValidUntil, + } +} + +func getConfigOptions(opt ...Option) configOptions { + opts := configOptionsDefault() + ApplyOpts(&opts, opt...) + + // Apply defaults to options + if opts.withGenerateAuthRequestID == nil { + opts.withGenerateAuthRequestID = DefaultGenerateAuthRequestID + } + if opts.withValidUntil == nil { + opts.withValidUntil = defaultValidUntil + } + + return opts +} + +// DefaultGenerateAuthRequestID generates an auth XSD:ID conform ID. +// A UUID prefixed with an underscore. +func DefaultGenerateAuthRequestID() (string, error) { + newID, err := uuid.GenerateUUID() + if err != nil { + return "", err + } + + // Request IDs have to be xsd:ID, which means they need to start with an underscore or letter, + // which is not always given for UUIDs. + return fmt.Sprintf("_%s", newID), nil +} + +// defaultValidUntil returns a timestamp with one year +// added to the time when this function is called. +func defaultValidUntil() time.Time { + return time.Now().Add(time.Hour * 24 * 365) +} diff --git a/saml/config_test.go b/saml/config_test.go new file mode 100644 index 00000000..ddac8040 --- /dev/null +++ b/saml/config_test.go @@ -0,0 +1,259 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package saml_test + +import ( + "strings" + "testing" + + "github.com/hashicorp/go-uuid" + "github.com/stretchr/testify/require" + + "github.com/hashicorp/cap/saml" + "github.com/hashicorp/cap/saml/models/core" +) + +func Test_NewConfig(t *testing.T) { + t.Parallel() + const ( + entityID = "http://test.me/entity" + acs = "http://test.me/sso/acs" + metadata = "http://test.me/sso/metadata" + ) + + cases := []struct { + name string + entityID string + acs string + issuer string + metadata string + opts []saml.Option + cfgOverride func(*saml.Config) + expectedErr string + }{ + { + name: "When all URLs are provided", + entityID: entityID, + acs: acs, + metadata: metadata, + expectedErr: "", + }, + { + name: "When there is no entity ID provided", + acs: acs, + metadata: metadata, + expectedErr: "saml.NewConfig: invalid provider config: saml.Config.Validate: EntityID not set: invalid parameter", + }, + { + name: "When there is no ACS URL provided", + entityID: entityID, + metadata: metadata, + expectedErr: "saml.NewConfig: invalid provider config: saml.Config.Validate: ACS URL not set: invalid parameter", + }, + { + name: "When there is no metadata URL provided", + acs: acs, + entityID: entityID, + expectedErr: "saml.NewConfig: invalid provider config: saml.Config.Validate: One of MetadataURL, MetadataXML, or MetadataParameters must be set: invalid parameter", + }, + { + name: "valid-WithMetadataParameters", + entityID: entityID, + acs: acs, + metadata: metadata, + opts: []saml.Option{ + saml.WithMetadataParameters(saml.MetadataParameters{ + Issuer: "https://samltest.id/idp", + SingleSignOnURL: "https://samltest.id/idp/profile/Shibboleth/SSO", + IDPCertificate: testEncodedMetadataCert, + Binding: core.ServiceBindingHTTPPost, + }), + }, + }, + { + name: "err-WithMetadataParameters-empty", + entityID: entityID, + acs: acs, + metadata: metadata, + opts: []saml.Option{ + saml.WithMetadataParameters(saml.MetadataParameters{}), + }, + expectedErr: "saml.Config.Validate: issuer not set", + }, + { + name: "err-WithMetadataParameters-invalid-issuer", + entityID: entityID, + acs: acs, + metadata: metadata, + opts: []saml.Option{ + saml.WithMetadataParameters(saml.MetadataParameters{ + Issuer: " https://samltest.id/idp", // extra space at the start makes it invalid + SingleSignOnURL: "https://samltest.id/idp/profile/Shibboleth/SSO", + IDPCertificate: testEncodedMetadataCert, + Binding: core.ServiceBindingHTTPPost, + }), + }, + expectedErr: "provided Issuer is not a valid URL", + }, + { + name: "err-WithMetadataParameters-missing-sso-url", + entityID: entityID, + acs: acs, + metadata: metadata, + opts: []saml.Option{ + saml.WithMetadataParameters(saml.MetadataParameters{ + Issuer: "https://samltest.id/idp", + SingleSignOnURL: "", + IDPCertificate: testEncodedMetadataCert, + Binding: core.ServiceBindingHTTPPost, + }), + }, + expectedErr: "SSO URL not set", + }, + { + name: "err-WithMetadataParameters-invalid-sso-url", + entityID: entityID, + acs: acs, + metadata: metadata, + opts: []saml.Option{ + saml.WithMetadataParameters(saml.MetadataParameters{ + Issuer: "https://samltest.id/idp", + SingleSignOnURL: " https://samltest.id/idp/profile/Shibboleth/SSO", // extra space at the start makes it invalid + IDPCertificate: testEncodedMetadataCert, + Binding: core.ServiceBindingHTTPPost, + }), + }, + expectedErr: "provided SSO URL is not a valid URL", + }, + { + name: "err-WithMetadataParameters-missing-cert", + entityID: entityID, + acs: acs, + metadata: metadata, + opts: []saml.Option{ + saml.WithMetadataParameters(saml.MetadataParameters{ + Issuer: "https://samltest.id/idp", + SingleSignOnURL: "https://samltest.id/idp/profile/Shibboleth/SSO", + IDPCertificate: "", + Binding: core.ServiceBindingHTTPPost, + }), + }, + expectedErr: "no certificate found", + }, + { + name: "err-WithMetadataParameters-extra-data", + entityID: entityID, + acs: acs, + metadata: metadata, + opts: []saml.Option{ + saml.WithMetadataParameters(saml.MetadataParameters{ + Issuer: "https://samltest.id/idp", + SingleSignOnURL: "https://samltest.id/idp/profile/Shibboleth/SSO", + IDPCertificate: testEncodedMetadataCert + "\nextra bits", + Binding: core.ServiceBindingHTTPPost, + }), + }, + expectedErr: "extra data found after certificate", + }, + { + name: "err-WithMetadataParameters-invalid-block-identifier", + entityID: entityID, + acs: acs, + metadata: metadata, + opts: []saml.Option{ + saml.WithMetadataParameters(saml.MetadataParameters{ + Issuer: "https://samltest.id/idp", + SingleSignOnURL: "https://samltest.id/idp/profile/Shibboleth/SSO", + IDPCertificate: testEncodedMetadataCertWithInvalidBlockIdentifier, + Binding: core.ServiceBindingHTTPPost, + }), + }, + expectedErr: `wrong block type found: "PRIVATE KEY"`, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := require.New(t) + got, err := saml.NewConfig( + c.entityID, + c.acs, + c.metadata, + c.opts..., + ) + + if c.expectedErr != "" { + r.ErrorContains(err, c.expectedErr) + return + } + r.NoError(err) + + r.Equal(got.EntityID, "http://test.me/entity") + r.Equal(got.AssertionConsumerServiceURL, "http://test.me/sso/acs") + r.Equal(got.MetadataURL, "http://test.me/sso/metadata") + + r.NotNil(got.GenerateAuthRequestID) + r.NotNil(got.ValidUntil) + }) + } +} + +func Test_GenerateAuthRequestID(t *testing.T) { + t.Parallel() + r := require.New(t) + + id, err := saml.DefaultGenerateAuthRequestID() + r.NoError(err) + + r.Contains(id, "_") + + splitted := strings.Split(id, "_") + + _, err = uuid.ParseUUID(splitted[1]) + r.NoError(err) +} + +const testEncodedMetadataCert = ` +-----BEGIN CERTIFICATE----- +MIIDEjCCAfqgAwIBAgIVAMECQ1tjghafm5OxWDh9hwZfxthWMA0GCSqGSIb3DQEB +CwUAMBYxFDASBgNVBAMMC3NhbWx0ZXN0LmlkMB4XDTE4MDgyNDIxMTQwOVoXDTM4 +MDgyNDIxMTQwOVowFjEUMBIGA1UEAwwLc2FtbHRlc3QuaWQwggEiMA0GCSqGSIb3 +DQEBAQUAA4IBDwAwggEKAoIBAQC0Z4QX1NFKs71ufbQwoQoW7qkNAJRIANGA4iM0 +ThYghul3pC+FwrGv37aTxWXfA1UG9njKbbDreiDAZKngCgyjxj0uJ4lArgkr4AOE +jj5zXA81uGHARfUBctvQcsZpBIxDOvUUImAl+3NqLgMGF2fktxMG7kX3GEVNc1kl +bN3dfYsaw5dUrw25DheL9np7G/+28GwHPvLb4aptOiONbCaVvh9UMHEA9F7c0zfF +/cL5fOpdVa54wTI0u12CsFKt78h6lEGG5jUs/qX9clZncJM7EFkN3imPPy+0HC8n +spXiH/MZW8o2cqWRkrw3MzBZW3Ojk5nQj40V6NUbjb7kfejzAgMBAAGjVzBVMB0G +A1UdDgQWBBQT6Y9J3Tw/hOGc8PNV7JEE4k2ZNTA0BgNVHREELTArggtzYW1sdGVz +dC5pZIYcaHR0cHM6Ly9zYW1sdGVzdC5pZC9zYW1sL2lkcDANBgkqhkiG9w0BAQsF +AAOCAQEASk3guKfTkVhEaIVvxEPNR2w3vWt3fwmwJCccW98XXLWgNbu3YaMb2RSn +7Th4p3h+mfyk2don6au7Uyzc1Jd39RNv80TG5iQoxfCgphy1FYmmdaSfO8wvDtHT +TNiLArAxOYtzfYbzb5QrNNH/gQEN8RJaEf/g/1GTw9x/103dSMK0RXtl+fRs2nbl +D1JJKSQ3AdhxK/weP3aUPtLxVVJ9wMOQOfcy02l+hHMb6uAjsPOpOVKqi3M8XmcU +ZOpx4swtgGdeoSpeRyrtMvRwdcciNBp9UZome44qZAYH1iqrpmmjsfI9pJItsgWu +3kXPjhSfj1AJGR1l9JGvJrHki1iHTA== +-----END CERTIFICATE----- +` + +const testEncodedMetadataCertWithInvalidBlockIdentifier = ` +-----BEGIN PRIVATE KEY----- +MIIDEjCCAfqgAwIBAgIVAMECQ1tjghafm5OxWDh9hwZfxthWMA0GCSqGSIb3DQEB +CwUAMBYxFDASBgNVBAMMC3NhbWx0ZXN0LmlkMB4XDTE4MDgyNDIxMTQwOVoXDTM4 +MDgyNDIxMTQwOVowFjEUMBIGA1UEAwwLc2FtbHRlc3QuaWQwggEiMA0GCSqGSIb3 +DQEBAQUAA4IBDwAwggEKAoIBAQC0Z4QX1NFKs71ufbQwoQoW7qkNAJRIANGA4iM0 +ThYghul3pC+FwrGv37aTxWXfA1UG9njKbbDreiDAZKngCgyjxj0uJ4lArgkr4AOE +jj5zXA81uGHARfUBctvQcsZpBIxDOvUUImAl+3NqLgMGF2fktxMG7kX3GEVNc1kl +bN3dfYsaw5dUrw25DheL9np7G/+28GwHPvLb4aptOiONbCaVvh9UMHEA9F7c0zfF +/cL5fOpdVa54wTI0u12CsFKt78h6lEGG5jUs/qX9clZncJM7EFkN3imPPy+0HC8n +spXiH/MZW8o2cqWRkrw3MzBZW3Ojk5nQj40V6NUbjb7kfejzAgMBAAGjVzBVMB0G +A1UdDgQWBBQT6Y9J3Tw/hOGc8PNV7JEE4k2ZNTA0BgNVHREELTArggtzYW1sdGVz +dC5pZIYcaHR0cHM6Ly9zYW1sdGVzdC5pZC9zYW1sL2lkcDANBgkqhkiG9w0BAQsF +AAOCAQEASk3guKfTkVhEaIVvxEPNR2w3vWt3fwmwJCccW98XXLWgNbu3YaMb2RSn +7Th4p3h+mfyk2don6au7Uyzc1Jd39RNv80TG5iQoxfCgphy1FYmmdaSfO8wvDtHT +TNiLArAxOYtzfYbzb5QrNNH/gQEN8RJaEf/g/1GTw9x/103dSMK0RXtl+fRs2nbl +D1JJKSQ3AdhxK/weP3aUPtLxVVJ9wMOQOfcy02l+hHMb6uAjsPOpOVKqi3M8XmcU +ZOpx4swtgGdeoSpeRyrtMvRwdcciNBp9UZome44qZAYH1iqrpmmjsfI9pJItsgWu +3kXPjhSfj1AJGR1l9JGvJrHki1iHTA== +-----END PRIVATE KEY----- +` diff --git a/saml/demo/.gitignore b/saml/demo/.gitignore new file mode 100644 index 00000000..1549b67c --- /dev/null +++ b/saml/demo/.gitignore @@ -0,0 +1 @@ +demo diff --git a/saml/demo/main.go b/saml/demo/main.go new file mode 100644 index 00000000..839ce85e --- /dev/null +++ b/saml/demo/main.go @@ -0,0 +1,71 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package main + +import ( + "fmt" + "html/template" + "net/http" + "os" + + "github.com/hashicorp/cap/saml" + "github.com/hashicorp/cap/saml/handler" +) + +func main() { + envs := map[string]string{ + "entityID": os.Getenv("CAP_SAML_ENTITY_ID"), + "acs": os.Getenv("CAP_SAML_ACS"), + "metadata": os.Getenv("CAP_SAML_METADATA"), + "metadata_xml": os.Getenv("CAP_SAML_METADATA_XML"), + } + + var options []saml.Option + if metaXML, ok := envs["metadata_xml"]; ok { + options = append(options, saml.WithMetadataXML(metaXML)) + } + + cfg, err := saml.NewConfig(envs["entityID"], envs["acs"], envs["metadata"], options...) + exitOnError(err) + + sp, err := saml.NewServiceProvider(cfg) + exitOnError(err) + + acsHandler, err := handler.ACSHandlerFunc(sp) + exitOnError(err) + + redirectHandler, err := handler.RedirectBindingHandlerFunc(sp) + exitOnError(err) + + postBindHandler, err := handler.PostBindingHandlerFunc(sp) + exitOnError(err) + + metadataHandler, err := handler.MetadataHandlerFunc(sp) + exitOnError(err) + + http.HandleFunc("/saml/acs", acsHandler) + http.HandleFunc("/saml/auth/redirect", redirectHandler) + http.HandleFunc("/saml/auth/post", postBindHandler) + http.HandleFunc("/metadata", metadataHandler) + http.HandleFunc("/login", func(w http.ResponseWriter, _ *http.Request) { + ts, _ := template.New("sso").Parse( + `
+
`, + ) + + ts.Execute(w, nil) + }) + + fmt.Println("Visit http://localhost:8000/login") + + err = http.ListenAndServe(":8000", nil) + exitOnError(err) +} + +func exitOnError(err error) { + if err != nil { + fmt.Printf("failed to run demo: %s", err.Error()) + os.Exit(1) + } +} diff --git a/saml/error.go b/saml/error.go new file mode 100644 index 00000000..43ed33eb --- /dev/null +++ b/saml/error.go @@ -0,0 +1,18 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package saml + +import "errors" + +var ( + ErrInternal = errors.New("internal error") + ErrBindingUnsupported = errors.New("Configured binding unsupported by the IDP") + ErrInvalidTLSCert = errors.New("invalid tls certificate") + ErrInvalidParameter = errors.New("invalid parameter") + ErrMissingAssertions = errors.New("missing assertions") + ErrInvalidTime = errors.New("invalid time") + ErrInvalidAudience = errors.New("invalid audience") + ErrMissingSubject = errors.New("subject missing") + ErrMissingAttributeStmt = errors.New("attribute statement missing") +) diff --git a/saml/go.mod b/saml/go.mod new file mode 100644 index 00000000..e0290509 --- /dev/null +++ b/saml/go.mod @@ -0,0 +1,27 @@ +module github.com/hashicorp/cap/saml + +go 1.20 + +require ( + github.com/beevik/etree v1.2.0 + github.com/crewjam/go-xmlsec v0.0.0-20200414151428-d2b1a58f7262 + github.com/crewjam/saml v0.4.14 + github.com/hashicorp/go-uuid v1.0.3 + github.com/jonboulle/clockwork v0.4.0 + github.com/russellhaering/gosaml2 v0.9.1 + github.com/russellhaering/goxmldsig v1.4.0 + github.com/stretchr/testify v1.8.4 +) + +require ( + github.com/crewjam/errset v0.0.0-20160219153700-f78d65de925c // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/ma314smith/signedxml v1.1.1 // indirect + github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/crypto v0.17.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/ma314smith/signedxml v1.1.1 => github.com/moov-io/signedxml v1.1.1 diff --git a/saml/go.sum b/saml/go.sum new file mode 100644 index 00000000..fc751e07 --- /dev/null +++ b/saml/go.sum @@ -0,0 +1,62 @@ +github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= +github.com/beevik/etree v1.2.0 h1:l7WETslUG/T+xOPs47dtd6jov2Ii/8/OjCldk5fYfQw= +github.com/beevik/etree v1.2.0/go.mod h1:aiPf89g/1k3AShMVAzriilpcE4R/Vuor90y83zVZWFc= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/crewjam/errset v0.0.0-20160219153700-f78d65de925c h1:dCJ9oZ0VgnzJHR5BjkSrwkXA1USu483qlxBd0u29P8s= +github.com/crewjam/errset v0.0.0-20160219153700-f78d65de925c/go.mod h1:XhiWL7J86xoqJ8+x2OA+AM2l9skQP2DZ0UOXQYVg7uI= +github.com/crewjam/go-xmlsec v0.0.0-20200414151428-d2b1a58f7262 h1:3V8RSsB1mxeAfxMb7lGSd0HlCHhc/ElJj1peaJMAkyk= +github.com/crewjam/go-xmlsec v0.0.0-20200414151428-d2b1a58f7262/go.mod h1:M9eHnKpImgRwzOFdlFQnbgJRqFwW/eX1cKAVobv03uE= +github.com/crewjam/saml v0.4.14 h1:g9FBNx62osKusnFzs3QTN5L9CVA/Egfgm+stJShzw/c= +github.com/crewjam/saml v0.4.14/go.mod h1:UVSZCf18jJkk6GpWNVqcyQJMD5HsRugBPf4I1nl2mME= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/jonboulle/clockwork v0.3.0/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4= +github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= +github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= +github.com/moov-io/signedxml v1.1.1 h1:TQ2fK4DRCYv7agH+z6RjtnBTmEyYMAztFzuHIPtUJpg= +github.com/moov-io/signedxml v1.1.1/go.mod h1:p+b4f/Wo/qKyew8fHW8VZOgsILWylyvvjdE68egzbwc= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/russellhaering/gosaml2 v0.9.1 h1:H/whrl8NuSoxyW46Ww5lKPskm+5K+qYLw9afqJ/Zef0= +github.com/russellhaering/gosaml2 v0.9.1/go.mod h1:ja+qgbayxm+0mxBRLMSUuX3COqy+sb0RRhIGun/W2kc= +github.com/russellhaering/goxmldsig v1.3.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= +github.com/russellhaering/goxmldsig v1.4.0 h1:8UcDh/xGyQiyrW+Fq5t8f+l2DLB1+zlhYzkPUJ7Qhys= +github.com/russellhaering/goxmldsig v1.4.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= diff --git a/saml/handler/acs.go b/saml/handler/acs.go new file mode 100644 index 00000000..81db2f67 --- /dev/null +++ b/saml/handler/acs.go @@ -0,0 +1,34 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package handler + +import ( + "fmt" + "net/http" + + "github.com/hashicorp/cap/saml" +) + +// ACSHandlerFunc creates a handler function that handles a SAML +// ACS request +func ACSHandlerFunc(sp *saml.ServiceProvider) (http.HandlerFunc, error) { + const op = "handler.ACSHandler" + switch { + case sp == nil: + return nil, fmt.Errorf("%s: missing service provider", op) + } + return func(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + samlResp := r.PostForm.Get("SAMLResponse") + + res, err := sp.ParseResponse(samlResp, "responseID", saml.InsecureSkipRequestIDValidation()) + if err != nil { + fmt.Println("failed to handle SAML response:", err.Error()) + http.Error(w, "failed to handle SAML response", http.StatusUnauthorized) + return + } + + fmt.Fprintf(w, "Authenticated! %+v", res) + }, nil +} diff --git a/saml/handler/metadata.go b/saml/handler/metadata.go new file mode 100644 index 00000000..4f9c0f39 --- /dev/null +++ b/saml/handler/metadata.go @@ -0,0 +1,29 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package handler + +import ( + "encoding/xml" + "fmt" + "net/http" + + "github.com/hashicorp/cap/saml" +) + +// MetadataHandlerFunc creates a handler function that handles a SAML +// metadata request +func MetadataHandlerFunc(sp *saml.ServiceProvider) (http.HandlerFunc, error) { + const op = "handler.MetadataHandlerFunc" + switch { + case sp == nil: + return nil, fmt.Errorf("%s: missing service provider", op) + } + return func(w http.ResponseWriter, _ *http.Request) { + meta := sp.CreateMetadata() + err := xml.NewEncoder(w).Encode(meta) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }, nil +} diff --git a/saml/handler/post_binding.go b/saml/handler/post_binding.go new file mode 100644 index 00000000..189e2ac9 --- /dev/null +++ b/saml/handler/post_binding.go @@ -0,0 +1,57 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package handler + +import ( + _ "embed" + "fmt" + "net/http" + + "github.com/hashicorp/cap/saml" +) + +// PostBindingHandlerFunc creates a handler function that handles a HTTP-POST binding SAML request. +func PostBindingHandlerFunc(sp *saml.ServiceProvider) (http.HandlerFunc, error) { + const op = "handler.PostBindingHandlerFunc" + switch { + case sp == nil: + return nil, fmt.Errorf("%s: missing service provider", op) + } + return func(w http.ResponseWriter, _ *http.Request) { + templ, _, err := sp.AuthnRequestPost("") + if err != nil { + http.Error( + w, + fmt.Sprintf("Failed to do SAML POST authentication request: %s", err.Error()), + http.StatusInternalServerError, + ) + return + } + + err = saml.WritePostBindingRequestHeader(w) + if err != nil { + http.Error( + w, + fmt.Sprintf( + "failed to write content headers: %s", + err.Error(), + ), + http.StatusInternalServerError, + ) + } + + _, err = w.Write(templ) + if err != nil { + http.Error( + w, + fmt.Sprintf( + "failed to serve post binding request: %s", + err.Error(), + ), + http.StatusInternalServerError, + ) + return + } + }, nil +} diff --git a/saml/handler/redirect_binding.go b/saml/handler/redirect_binding.go new file mode 100644 index 00000000..86c3b9e9 --- /dev/null +++ b/saml/handler/redirect_binding.go @@ -0,0 +1,38 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package handler + +import ( + "fmt" + "net/http" + + "github.com/hashicorp/cap/saml" +) + +// RedirectBindingHandlerFunc creates a handler function that handles a SAML +// redirect request. +func RedirectBindingHandlerFunc(sp *saml.ServiceProvider) (http.HandlerFunc, error) { + const op = "handler.RedirectBindingHandlerFunc" + switch { + case sp == nil: + return nil, fmt.Errorf("%s: missing service provider", op) + } + return func(w http.ResponseWriter, r *http.Request) { + redirectURL, _, err := sp.AuthnRequestRedirect("relayState") + if err != nil { + http.Error( + w, + fmt.Sprintf("failed to create SAML Authn Request: %s", err.Error()), + http.StatusInternalServerError, + ) + return + } + + redirect := redirectURL.String() + + fmt.Printf("Redirect URL: %s\n", redirect) + + http.Redirect(w, r, redirect, http.StatusFound) + }, nil +} diff --git a/saml/is_nil.go b/saml/is_nil.go new file mode 100644 index 00000000..0bc17a20 --- /dev/null +++ b/saml/is_nil.go @@ -0,0 +1,18 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package saml + +import "reflect" + +// isNil reports if a is nil +func isNil(a any) bool { + if a == nil { + return true + } + switch reflect.TypeOf(a).Kind() { + case reflect.Ptr, reflect.Map, reflect.Chan, reflect.Slice, reflect.Func: + return reflect.ValueOf(a).IsNil() + } + return false +} diff --git a/saml/is_nil_test.go b/saml/is_nil_test.go new file mode 100644 index 00000000..4bed7193 --- /dev/null +++ b/saml/is_nil_test.go @@ -0,0 +1,59 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package saml + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_isNil(t *testing.T) { + t.Parallel() + + var testErrNilPtr *testError + var testMapNilPtr map[string]struct{} + var testArrayNilPtr *[1]string + var testChanNilPtr *chan string + var testSliceNilPtr *[]string + var testFuncNil func() + + var testChanString chan string + + tc := []struct { + i any + want bool + }{ + {i: &testError{}, want: false}, + {i: testError{}, want: false}, + {i: &map[string]struct{}{}, want: false}, + {i: map[string]struct{}{}, want: false}, + {i: [1]string{}, want: false}, + {i: &[1]string{}, want: false}, + {i: &testChanString, want: false}, + {i: "string", want: false}, + {i: []string{}, want: false}, + {i: func() {}, want: false}, + {i: nil, want: true}, + {i: testErrNilPtr, want: true}, + {i: testMapNilPtr, want: true}, + {i: testArrayNilPtr, want: true}, + {i: testChanNilPtr, want: true}, + {i: testChanString, want: true}, + {i: testSliceNilPtr, want: true}, + {i: testFuncNil, want: true}, + } + + for i, tc := range tc { + t.Run(fmt.Sprintf("test #%d", i+1), func(t *testing.T) { + assert := assert.New(t) + assert.Equal(tc.want, isNil(tc.i)) + }) + } +} + +type testError struct{} + +func (*testError) Error() string { return "error" } diff --git a/saml/models/core/common.go b/saml/models/core/common.go new file mode 100644 index 00000000..a2f22c70 --- /dev/null +++ b/saml/models/core/common.go @@ -0,0 +1,224 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package core + +import ( + "encoding/xml" + "time" + + "github.com/crewjam/go-xmlsec/xmlenc" +) + +const ( + SAMLVersion2 = "2.0" +) + +type ServiceBinding string + +const ( + ServiceBindingHTTPPost ServiceBinding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" + ServiceBindingHTTPRedirect ServiceBinding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" + ServiceBindingSOAP ServiceBinding = "urn:oasis:names:tc:SAML:2.0:bindings:SOAP" +) + +// See 8.3 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type NameIDFormat string + +const ( + // See 8.3.1 - 8.3.8 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf + NameIDFormatUnspecified NameIDFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" + NameIDFormatEmail NameIDFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" + NameIDFormatX509SubjectName NameIDFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName" + NameIDFormatWindowsDomainQualifiedName NameIDFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:WindowsDomainQualifiedName" + NameIDFormatKerberos NameIDFormat = "urn:oasis:names:tc:SAML:2.0:nameid-format:kerberos" + NameIDFormatEntity NameIDFormat = "urn:oasis:names:tc:SAML:2.0:nameid-format:entity" + NameIDFormatPersistent NameIDFormat = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" + NameIDFormatTransient NameIDFormat = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient" +) + +type NameFormat string + +const ( + NameFormatURI NameFormat = "urn:oasis:names:tc:SAML:2.0:attrname-format:uri" +) + +// StatusCodeType defines the possible status codes in a SAML Response. +// The possible status codes are defined in: +// 3.2.2.2 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type StatusCodeType string + +const ( + // StatusCodeSuccess indicates that the request succeeded. + StatusCodeSuccess StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:Success" + + // StatusCodeRequester indicates that the request could not be performed due to + // an error on the part of the requester. + StatusCodeRequester StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:Requester" + + // StatusCodeResponder indicatest that the request could not be performed due to + // an error on the part of the SAML responder or SAML authority. + StatusCodeResponder StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:Responder" + + // StatusCodeVersionMismatch indicates that the SAML responder could not process the + // request because the version of the request message was incorrect. + StatusCodeVersionMismatch StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:VersionMismatch" + + // StatusCodeAuthnFailed indicates that the responding provider was unable to successfully + // authenticate the principal. + StatusCodeAuthnFailed StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:AuthnFailed" + + // StatusCodeInvalidAttrNameOrValue indicates that an unexpected or invalid content was + // encountered within a or element. + StatusCodeInvalidAttrNameOrValue StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:InvalidAttrNameOrValue" + + // StatusCodeInvalidNameIDPolicy indicates that the responding provider cannot or will not support the + // requested name identifier policy. + StatusCodeInvalidNameIDPolicy StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:InvalidNameIDPolicy" + + // StatusCodeNoAuthnContext indicates that the specified authentication context requirements cannot + // be met by the responder. + StatusCodeNoAuthnContext StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:NoAuthnContext" + + // StatusCodeNoAvailableIDP indicates that the Used by an intermediary to indicate that none of the + // supported identity provider elements in an can be resolved or that none of the + // supported identity providers are available. + StatusCodeNoAvailableIDP StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:NoAvailableIDP" + + // StatusCodeNoPassive indicates that the responding provider cannot authenticate the principal passively, + // as has been requested. + StatusCodeNoPassive StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:NoPassive" + + // StatusCodeNoSupportedIDP is used by an intermediary to indicate that none of the identity providers in an + // are supported by the intermediary. + StatusCodeNoSupportedIDP StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:NoSupportedIDP" + + // StatusCodePartialLogout is used by a session authority to indicate to a session participant that it + // was not able to propagate logout to all other session participants. + StatusCodePartialLogout StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:PartialLogout" + + // StatusCodeProxyCountExceeded indicates that a responding provider cannot authenticate the principal + // directly and is not permitted to proxy the request further. + StatusCodeProxyCountExceeded StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:ProxyCountExceeded" + + // StatusCodeRequestDenied indicates that the SAML responder or SAML authority is able to process the + // request but has chosen not to respond. This status code MAY be used when there is concern about the + // security context of the request message or the sequence of request messages received from a particular + // requester. + StatusCodeRequestDenied StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:RequestDenied" + + // StatusCodeRequestUnsupported indicates that the SAML responder or SAML authority does not support the + // request. + StatusCodeRequestUnsupported StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:RequestUnsupported" + + // StatusCodeRequestVersionDeprecated indicates that the SAML responder cannot process any requests with + // the protocol version specified in the request. + StatusCodeRequestVersionDeprecated StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:RequestVersionDeprecated" + + // StatusCodeRequestRequestVersionTooHigh indicates that the SAML responder cannot process the request because + // the protocol version specified in the request message is a major upgrade from the highest protocol version + // supported by the responder. + StatusCodeRequestRequestVersionTooHigh StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:RequestVersionTooHigh" + + // StatusCodeRequestRequestVersionTooLow indicates that the SAML responder cannot process the request because + // the protocol version specified in the request message is too low. + StatusCodeRequestVersionTooLow StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:RequestVersionTooLow" + + // StatusCodeRequestResourceNotRecognized indicates that the resource value provided in the request message is + // invalid or unrecognized. + StatusCodeResourceNotRecognized StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:ResourceNotRecognized" + + // StatusCodeTooManyResponses indicates that the response message would contain more elements than the SAML + // responder is able to return. + StatusCodeTooManyResponses StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:TooManyResponses" + + // StatusCodeUnknownAttrProfile indicates that an entity that has no knowledge of a particular attribute + // profile has been presented with an attribute drawn from that profile. + StatusCodeUnknownAttrProfile StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:UnknownAttrProfile" + + // StatusCodeUnknownPrincipal indicates that the responding provider does not recognize the principal + // specified or implied by the request. + StatusCodeUnknownPrincipal StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:UnknownPrincipal" + + // StatusCodeUnsupportedBinding indicates that the SAML responder cannot properly fulfill the request using + // the protocol binding specified in the request. + StatusCodeUnsupportedBinding StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:UnsupportedBinding" +) + +// ConfirmationMethod indicates the sepcific method to be used by the relying parte to determine +// that the request or message came from a system entity that is associated with the subject of +// the assertion, within the context of a particular profile. +// +// See 3. http://docs.oasis-open.org/security/saml/v2.0/saml-profiles-2.0-os.pdf +type ConfirmationMethod string + +const ( + // ConfirmationMethodHolderOfKey indicates that the key holder itself can confirm + // itself as the subject. If this method is given, the SubjectConfirmationData MUST + // contain one or more KeyInfo elements, where KeyInfo identifies a cryptographic key. + // + // See 3.1 http://docs.oasis-open.org/security/saml/v2.0/saml-profiles-2.0-os.pdf + ConfirmationMethodHolderOfKey ConfirmationMethod = "urn:oasis:names:tc:SAML:2.0:cm:holder-of-key" + + // ConfirmationMethodSenderVouches indicates that no other information is available about + // the context of use of the assertion. + // + // See 3.2 http://docs.oasis-open.org/security/saml/v2.0/saml-profiles-2.0-os.pdf + ConfirmationMethodSenderVouches ConfirmationMethod = "urn:oasis:names:tc:SAML:2.0:cm:sender-vouches" + + // ConfirmationMethodBearer indicates that the bearer can confirm itself as the subject. + // + // See 3.3 http://docs.oasis-open.org/security/saml/v2.0/saml-profiles-2.0-os.pdf + ConfirmationMethodBearer ConfirmationMethod = "urn:oasis:names:tc:SAML:2.0:cm:bearer" +) + +// See 3.2 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type RequestResponseCommon struct { + ID string `xml:",attr"` // required + Version string `xml:",attr"` // required + + // The time instant of issue of the request. + IssueInstant time.Time `xml:",attr"` // required + Consent string `xml:",attr,omitempty"` // optional TODO: define constants + Issuer *Issuer // recommended + Singature string `xml:",omitempty"` // recommended + Extensions *Extensions // optional + Destination string `xml:",attr"` +} + +// See 2.2.1 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type BaseID struct { + NameQualifier string `xml:",attr,omitempty"` + SPNameQualifier string `xml:",attr,omitempty"` +} + +// See 2.2.2 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type NameIDType struct { + NameQualifier string `xml:",attr,omitempty"` + SPNameQualifier string `xml:",attr,omitempty"` + Format NameIDFormat `xml:",attr,omitempty"` + SPProvidedID string `xml:",attr,omitempty"` + + Value string `xml:",chardata"` +} + +// See 2.2.3 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type NameID = NameIDType + +// See 2.2.3 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type EncryptedID struct { + EncryptedData xmlenc.EncryptedData + EncryptedKey xmlenc.EncryptedKey +} + +// Issuer, with type NameIDType, provides information about the issuer of a SAML assertion. +// See 2.2.5 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type Issuer struct { + XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:assertion Issuer"` + + NameIDType +} + +// Indicates that an attribute is yet to be defined. +// It is only used to for development purposes. +type TBD struct{} diff --git a/saml/models/core/fixtures/response.xml.go b/saml/models/core/fixtures/response.xml.go new file mode 100644 index 00000000..9bc7b01d --- /dev/null +++ b/saml/models/core/fixtures/response.xml.go @@ -0,0 +1,95 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package fixtures + +var ResponseXML = ` + + https://samltest.id/saml/idp + + + + + + + + + + + + + Hs5IUzabpy3X7gqpi0FbyGQoqgVaNwfAQvHymdEHJtE= + + + jgRgXKmIhn/OGcScnKC2zkg/kIEnThE8CzxqkG1cM2UHgkjB+zB2CkxJ/TmjYL+qljjJmeijgkabwhiDMwVJ62tEYv2Ck5OliRyF2mvO+lV0XIFjbXIvJm20R3xP3US23Vj6UpFX/kqlgD//K/v8uS4KENVok0UCQgqXT8JtDTCSmg6aV+boE8KrgFsKXX75zH7ZpUDOIDakmNXDXsS/y7xTtu23YNHLCiP99Px22kJ+cDk30I7/w2DN85si6dvmfbV4jSwFQHyf4ZT6RRk0TkOjTCEkN6qDdEOsbUPDYurUXeDUD2WU2YMCE0JDaymPedh1JtNoQS64UQssjTduFA== + + + MIIDEjCCAfqgAwIBAgIVAMECQ1tjghafm5OxWDh9hwZfxthWMA0GCSqGSIb3DQEBCwUAMBYxFDAS BgNVBAMMC3NhbWx0ZXN0LmlkMB4XDTE4MDgyNDIxMTQwOVoXDTM4MDgyNDIxMTQwOVowFjEUMBIG A1UEAwwLc2FtbHRlc3QuaWQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0Z4QX1NFK s71ufbQwoQoW7qkNAJRIANGA4iM0ThYghul3pC+FwrGv37aTxWXfA1UG9njKbbDreiDAZKngCgyj xj0uJ4lArgkr4AOEjj5zXA81uGHARfUBctvQcsZpBIxDOvUUImAl+3NqLgMGF2fktxMG7kX3GEVN c1klbN3dfYsaw5dUrw25DheL9np7G/+28GwHPvLb4aptOiONbCaVvh9UMHEA9F7c0zfF/cL5fOpd Va54wTI0u12CsFKt78h6lEGG5jUs/qX9clZncJM7EFkN3imPPy+0HC8nspXiH/MZW8o2cqWRkrw3 MzBZW3Ojk5nQj40V6NUbjb7kfejzAgMBAAGjVzBVMB0GA1UdDgQWBBQT6Y9J3Tw/hOGc8PNV7JEE 4k2ZNTA0BgNVHREELTArggtzYW1sdGVzdC5pZIYcaHR0cHM6Ly9zYW1sdGVzdC5pZC9zYW1sL2lk cDANBgkqhkiG9w0BAQsFAAOCAQEASk3guKfTkVhEaIVvxEPNR2w3vWt3fwmwJCccW98XXLWgNbu3 YaMb2RSn7Th4p3h+mfyk2don6au7Uyzc1Jd39RNv80TG5iQoxfCgphy1FYmmdaSfO8wvDtHTTNiL ArAxOYtzfYbzb5QrNNH/gQEN8RJaEf/g/1GTw9x/103dSMK0RXtl+fRs2nblD1JJKSQ3AdhxK/we P3aUPtLxVVJ9wMOQOfcy02l+hHMb6uAjsPOpOVKqi3M8XmcUZOpx4swtgGdeoSpeRyrtMvRwdcci NBp9UZome44qZAYH1iqrpmmjsfI9pJItsgWu3kXPjhSfj1AJGR1l9JGvJrHki1iHTA== + + + + + + + + https://samltest.id/saml/idp + + rsanchez@samltest.id + + + + + + + http://saml.julz/example + + + + + + urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport + + + + + urn:mace:dir:entitlement:common-lib-terms + + + rick + + + rsanchez@samltest.id + + + +1-555-555-5515 + + + manager@Samltest.id + + + rsanchez@samltest.id + + + Sanchez + + + Rick Sanchez + + + Rick + + + +` + +var ResponseXMLIssuer = ` + + https://samltest.id/saml/idp +` + +var ResponseXMLStatus = ` + + + + +` diff --git a/saml/models/core/request.go b/saml/models/core/request.go new file mode 100644 index 00000000..4216131e --- /dev/null +++ b/saml/models/core/request.go @@ -0,0 +1,173 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package core + +import ( + "encoding/xml" + "strings" + "time" +) + +// See 3.2.1 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type StatusRequestType struct { + RequestResponseCommon +} + +// See 3.4.1 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +// TODO Finish this +type AuthnRequest struct { + XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:protocol AuthnRequest"` + + StatusRequestType + + Subject *Subject + NameIDPolicy *NameIDPolicy `xml:",omitempty"` + Conditions *Conditions + RequestedAuthContext *RequestedAuthnContext + Scoping *Scoping + + ForceAuthn bool `xml:",attr,omitempty"` + IsPassive bool `xml:",attr,omitempty"` + + AssertionConsumerServiceIndex string `xml:",attr,omitempty"` + AssertionConsumerServiceURL string `xml:",attr"` + + // A URI reference that identifies a SAML protocol binding to be used when + // returning the Response message. + ProtocolBinding ServiceBinding `xml:",attr"` + + AttributeConsumingServiceIndex string `xml:",attr,omitempty"` + ProviderName string `xml:",attr,omitempty"` +} + +// Subject specifies the requested subject of the resulting assertion(s). +// If entirely omitted or if no identifier is included, the presenter of +// the message is presumed to be the requested subject. +// +// See 2.4 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type Subject struct { + XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:assertion Subject"` + + SubjectConfirmation []*SubjectConfirmation + + BaseID *BaseID // optional + NameID *NameID // optional + EncryptedID *EncryptedID // optional +} + +// See 2.4.1.1 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type SubjectConfirmation struct { + Method ConfirmationMethod `xml:",attr"` // required + + SubjectConfirmationData *SubjectConfirmationData // optional + + BaseID *BaseID // optional + NameID *NameID // optional + EncryptedID *EncryptedID // optional +} + +// See 2.4.1.2 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type SubjectConfirmationData struct { + NotBefore time.Time `xml:",attr"` // optional + NotOnOrAfter time.Time `xml:",attr"` // optional + Recipient string `xml:",attr"` // optional + InResponseTo string `xml:",attr"` // optional + Address string `xml:",attr"` // optional +} + +/* TODO: Create a function to validate this: +Note that the time period specified by the optional NotBefore and NotOnOrAfter attributes, if present, +SHOULD fall within the overall assertion validity period as specified by the element's +NotBefore and NotOnOrAfter attributes. If both attributes are present, the value for NotBefore +MUST be less than (earlier than) the value for NotOnOrAfter. +*/ + +// NameIDPolicy specifies constraints on the name identifier to be used to represent +// the requested subject. +// See 3.4.1.1 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type NameIDPolicy struct { + Format NameIDFormat `xml:",omitempty"` + SPNameQualifier string `xml:",attr,omitempty"` + AllowCreate bool `xml:",attr"` +} + +// Scoping ... (TODO: not important for the first MVP) +// See 3.4.1.2 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type Scoping struct { + // ProxyCount specifies the number of proxying indirections permissible between the + // identity provider that receives this AuthnRequest and the identity provider who + // ultimately authenticates the principal. + ProxyCount int `xml:",attr"` + + IDPList *IDPList + + RequesterID []string +} + +// IDPList specifies the identity providers trusted by the requester to authenticate the +// presenter. +// See 3.4.1.3 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type IDPList struct { + IDPEntry []*IDPEntry + GetComplete []string // TODO is this correct? +} + +// IDPEntry specifies a single identity provider trusted by the requester to authenticate the +// presenter. +// See 3.4.1.3 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type IDPEntry struct { + // ProviderID is the unique identifier of the identity provider. + ProviderID string `xml:",attr"` + + // Name is a human-readable name for the identity provider. + Name string + + // Loc is a URI reference representing the location of a profile-specific endpoint + // supporting the authentication request protocol. + Loc string +} + +type Conditions struct{} + +// Comparison specifies the comparison method used to evaluate the requested context classes or statements. +// Possible values: "exact", "minimum", "maximum", "better" +type Comparison string + +const ( + // ComparisonExact requires that the resulting authentication context in the authentication + // statement MUST be the exact match of at least one of the authentication contexts specified. + ComparisonExact Comparison = "exact" // default + + // ComparisonMin requires that the resulting authentication context in the authentication + // statement MUST be at least as strong (as deemed by the responder) as one of the authentication + // contexts specified. + ComparsionMin Comparison = "minimum" + + // ComparisonMax requires that the resulting authentication context in the authentication + // statement MUST be stronger (as deemed by the responder) than any one of the authentication contexts + // specified. + ComparsionMax Comparison = "maximum" + + // ComparisonBetter requires that the resulting authentication context in the authentication + // statement MUST be as strong as possible (as deemed by the responder) without exceeding the strength + // of at least one of the authentication contexts specified. + ComparisonBetter Comparison = "better" +) + +// RequestedAuthnContext specifies the authentication context requirements of +// authentication statements returned in response to a request or query. +// See 3.3.2.2.1 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type RequestedAuthnContext struct { + XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:protocol RequestedAuthnContext"` + + AuthnContextClassRef []string `xml:"urn:oasis:names:tc:SAML:2.0:assertion AuthnContextClassRef"` + Comparison Comparison `xml:",attr"` +} + +type Extensions struct{} + +// CreateXMLDocument creates an AuthnRequest XML document. +func (a *AuthnRequest) CreateXMLDocument(indent int) ([]byte, error) { + return xml.MarshalIndent(a, "", strings.Repeat(" ", indent)) +} diff --git a/saml/models/core/response.go b/saml/models/core/response.go new file mode 100644 index 00000000..40404b3b --- /dev/null +++ b/saml/models/core/response.go @@ -0,0 +1,81 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package core + +import ( + "github.com/russellhaering/gosaml2/types" +) + +// Response is a SAML Response element. +// See 3.3.3 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type Response struct { + types.Response +} + +// Assertions returns the assertions in the Response. +func (r *Response) Assertions() []Assertion { + assertions := make([]Assertion, 0, len(r.Response.Assertions)) + for _, assertion := range r.Response.Assertions { + assertions = append(assertions, Assertion{Assertion: assertion}) + } + + return assertions +} + +// Issuer returns the issuer of the Response if it exists. +// Otherwise, it returns an empty string. +func (r *Response) Issuer() string { + if r.Response.Issuer == nil { + return "" + } + + return r.Response.Issuer.Value +} + +// Assertion is a SAML Assertion element. +// See 2.3.3 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type Assertion struct { + types.Assertion +} + +// Attribute is a SAML Attribute element. +// See 2.7.3.1 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type Attribute struct { + types.Attribute +} + +// Issuer returns the issuer of the Assertion if it exists. +// Otherwise, it returns an empty string. +func (a *Assertion) Issuer() string { + if a.Assertion.Issuer == nil { + return "" + } + + return a.Assertion.Issuer.Value +} + +// SubjectNameID returns the value of the NameID element if it exists in +// the Subject of the Assertion. Otherwise, it returns an empty string. +func (a *Assertion) SubjectNameID() string { + if a.Subject == nil || a.Subject.NameID == nil { + return "" + } + + return a.Subject.NameID.Value +} + +// Attributes returns the attributes of the Assertion. If there is no +// AttributeStatement or no contained Attributes, an empty list is returned. +func (a *Assertion) Attributes() []Attribute { + if a.AttributeStatement == nil { + return []Attribute{} + } + + attributes := make([]Attribute, 0, len(a.AttributeStatement.Attributes)) + for _, attribute := range a.AttributeStatement.Attributes { + attributes = append(attributes, Attribute{Attribute: attribute}) + } + + return attributes +} diff --git a/saml/models/core/response_test.go b/saml/models/core/response_test.go new file mode 100644 index 00000000..5d0bd9cc --- /dev/null +++ b/saml/models/core/response_test.go @@ -0,0 +1,179 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package core_test + +import ( + "encoding/xml" + "testing" + + "github.com/hashicorp/cap/saml/models/core" + "github.com/stretchr/testify/require" +) + +func TestResponse(t *testing.T) { + tests := []struct { + name string + responseXML string + assertions func(*testing.T, core.Response) + }{ + { + name: "response container", + responseXML: responseXMLContainer, + assertions: func(t *testing.T, response core.Response) { + require.Equal(t, response.Destination, "http://localhost:8000/saml/acs") + require.Equal(t, response.ID, "saml-response-id") + require.Equal(t, response.IssueInstant.String(), "2023-03-31 06:55:44.494 +0000 UTC") + require.Equal(t, response.Version, "2.0") + }, + }, + { + name: "assertions helper", + responseXML: responseXMLAssertion, + assertions: func(t *testing.T, response core.Response) { + assertions := response.Assertions() + require.Len(t, assertions, 1) + assertion := assertions[0] + + require.Equal(t, "assertion-id", assertion.ID) + require.Equal(t, "2023-03-31 06:55:44.494 +0000 UTC", assertion.IssueInstant.String()) + require.Equal(t, "2.0", assertion.Version) + }, + }, + { + name: "assertion subject helper", + responseXML: responseXMLAssertionSubject, + assertions: func(t *testing.T, response core.Response) { + assertions := response.Assertions() + require.Len(t, assertions, 1) + assertion := assertions[0] + + require.Equal(t, "someone@samltest.id", assertion.SubjectNameID()) + require.EqualValues(t, core.ConfirmationMethodBearer, assertion.Subject.SubjectConfirmation.Method) + require.Equal(t, "http://localhost:8000/saml/acs", assertion.Subject.SubjectConfirmation.SubjectConfirmationData.Recipient) + require.Equal(t, "request-id", assertion.Subject.SubjectConfirmation.SubjectConfirmationData.InResponseTo) + }, + }, + { + name: "assertion issuer helper", + responseXML: responseXMLAssertionIssuer, + assertions: func(t *testing.T, response core.Response) { + assertions := response.Assertions() + require.Len(t, assertions, 1) + assertion := assertions[0] + + require.Equal(t, "https://samltest.id/saml/idp", assertion.Issuer()) + }, + }, + { + name: "response issuer helper", + responseXML: responseXMLIssuer, + assertions: func(t *testing.T, response core.Response) { + require.Equal(t, "https://samltest.id/saml/idp2", response.Issuer()) + }, + }, + { + name: "response status code", + responseXML: responseXMLStatus, + assertions: func(t *testing.T, response core.Response) { + require.Equal(t, string(core.StatusCodeSuccess), response.Status.StatusCode.Value) + }, + }, + { + name: "assertion attributes helper", + responseXML: responseXMLAssertionAttributes, + assertions: func(t *testing.T, response core.Response) { + assertions := response.Assertions() + require.Len(t, assertions, 1) + assertion := assertions[0] + attributes := assertion.Attributes() + require.Len(t, attributes, 3) + require.Equal(t, "telephoneNumber", attributes[0].FriendlyName) + require.Equal(t, "+1-555-555-5555", attributes[0].Values[0].Value) + require.Equal(t, "+1-777-777-7777", attributes[0].Values[1].Value) + require.Equal(t, "email", attributes[1].FriendlyName) + require.Equal(t, "rsanchez@samltest.id", attributes[1].Values[0].Value) + require.Equal(t, "givenName", attributes[2].FriendlyName) + require.Equal(t, "Rick", attributes[2].Values[0].Value) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + response := responseXML(t, tt.responseXML) + tt.assertions(t, response) + }) + } +} + +func responseXML(t *testing.T, ssoRes string) core.Response { + t.Helper() + + res := core.Response{} + err := xml.Unmarshal([]byte(ssoRes), &res) + require.NoError(t, err) + return res +} + +const ( + responseXMLContainer = ` + +` + + responseXMLIssuer = ` + + https://samltest.id/saml/idp2 +` + + responseXMLStatus = ` + + + + +` + + responseXMLAssertion = ` + + + +` + + responseXMLAssertionIssuer = ` + + + https://samltest.id/saml/idp + +` + + responseXMLAssertionSubject = ` + + + + someone@samltest.id + + + + + +` + + responseXMLAssertionAttributes = ` + + + + + +1-555-555-5555 + +1-777-777-7777 + + + rsanchez@samltest.id + + + Rick + + + +` +) diff --git a/saml/models/metadata/common.go b/saml/models/metadata/common.go new file mode 100644 index 00000000..8544b2c9 --- /dev/null +++ b/saml/models/metadata/common.go @@ -0,0 +1,39 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package metadata + +import "github.com/hashicorp/cap/saml/models/core" + +/* + This file defines common types used in defining SAML v2.0 Metadata elements and + Attributes. + See 2.2 Common Types - http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf +*/ + +// EndpointType describes a SAML protocol binding endpoint at which a SAML entity can +// be sent protocol messages. +// See 2.2.2 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf +type Endpoint struct { + Binding core.ServiceBinding `xml:",attr"` + Location string `xml:",attr"` + ResponseLocation string `xml:",attr,omitempty"` +} + +// IndexedEndpointType extends EndpointType with a pair of attributes to permit the +// indexing of otherwise identical endpoints so that they can be referenced by protocol messages. +// See 2.2.3 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf +type IndexedEndpoint struct { + Endpoint + Index int `xml:"index,attr"` + IsDefault bool `xml:"isDefault,attr,omitempty"` +} + +// Localized is used to represent the SAML types: +// - localizedName +// - localizedURI +// See 2.2.4 & 2.2.5 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf +type Localized struct { + Lang string `xml:"http://www.w3.org/XML/1998/namespace lang,attr"` + Value string `xml:",chardata"` +} diff --git a/saml/models/metadata/duration.go b/saml/models/metadata/duration.go new file mode 100644 index 00000000..e020cd11 --- /dev/null +++ b/saml/models/metadata/duration.go @@ -0,0 +1,25 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package metadata + +import ( + "time" + + crewjamSaml "github.com/crewjam/saml" +) + +// Duration is a time.Duration that uses the xsd:duration format for text +// marshalling and unmarshalling. +type Duration time.Duration + +// MarshalText implements the encoding.TextMarshaler interface. +func (d Duration) MarshalText() ([]byte, error) { + return crewjamSaml.Duration(d).MarshalText() +} + +// UnmarshalText implements the encoding.TextUnmarshaler interface. +func (d *Duration) UnmarshalText(text []byte) error { + cp := (*crewjamSaml.Duration)(d) + return cp.UnmarshalText(text) +} diff --git a/saml/models/metadata/duration_test.go b/saml/models/metadata/duration_test.go new file mode 100644 index 00000000..ff1409ff --- /dev/null +++ b/saml/models/metadata/duration_test.go @@ -0,0 +1,59 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package metadata + +import ( + "errors" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +var durationMarshalTests = []struct { + in time.Duration + expected []byte +}{ + {0, nil}, + {time.Hour, []byte("PT1H")}, + {-time.Hour, []byte("-PT1H")}, +} + +func TestDuration(t *testing.T) { + for i, testCase := range durationMarshalTests { + t.Run(strconv.Itoa(i), func(t *testing.T) { + actual, err := Duration(testCase.in).MarshalText() + require.NoError(t, err) + require.Equal(t, testCase.expected, actual) + }) + } +} + +var durationUnmarshalTests = []struct { + in []byte + expected time.Duration + err error +}{ + {nil, 0, nil}, + {[]byte("-PT1H"), -time.Hour, nil}, + {[]byte("P1D"), 24 * time.Hour, nil}, + {[]byte("P1M"), 720 * time.Hour, nil}, + {[]byte("PT1.S"), 0, errors.New("invalid duration (PT1.S)")}, +} + +func TestDurationUnmarshal(t *testing.T) { + for i, testCase := range durationUnmarshalTests { + t.Run(strconv.Itoa(i), func(t *testing.T) { + var actual Duration + err := actual.UnmarshalText(testCase.in) + if testCase.err == nil { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, testCase.err.Error()) + } + require.Equal(t, Duration(testCase.expected), actual) + }) + } +} diff --git a/saml/models/metadata/entity_descriptor.go b/saml/models/metadata/entity_descriptor.go new file mode 100644 index 00000000..dc1fe2c8 --- /dev/null +++ b/saml/models/metadata/entity_descriptor.go @@ -0,0 +1,172 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package metadata + +import ( + "time" + + "github.com/beevik/etree" + dsig "github.com/russellhaering/goxmldsig/types" + + "github.com/hashicorp/cap/saml/models/core" +) + +type ContactType string + +const ( + ContactTypeTechnical ContactType = "technical" + ContactTypeSupport ContactType = "support" + ContactTypeAdministrative ContactType = "administrative" + ContactTypeBilling ContactType = "billing" + ContactTypeOther ContactType = "other" +) + +type ProtocolSupportEnumeration string + +const ( + ProtocolSupportEnumerationProtocol ProtocolSupportEnumeration = "urn:oasis:names:tc:SAML:2.0:protocol" +) + +// KeyType defines what the key is used for. +// Possible values are "encryption" and "signing". +// See 2.4.1.1 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf +type KeyType string + +const ( + KeyTypeEncryption KeyType = "encryption" + KeyTypeSigning KeyType = "signing" +) + +// DescriptorCommon defines common fields used in Entity- and EntitiesDescriptor. +type DescriptorCommon struct { + ID string `xml:",attr,omitempty"` + ValidUntil *time.Time `xml:"validUntil,attr,omitempty"` + CacheDuration *Duration `xml:"cacheDuration,attr,omitempty"` + Signature *dsig.Signature +} + +// EntitiesDescriptor is a container that wraps one or more elements of +// EntityDiscriptor. +// See 2.3.1 in http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf +type EntitiesDescriptor struct { + DescriptorCommon + + Name string + + EntitiesDescriptor []*EntitiesDescriptor + EntityDescriptor []*EntityDescriptor +} + +// EntityDescriptor represents a system entity (IdP or SP) in metadata. +// See 2.3.2 in http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf +type EntityDescriptor struct { + DescriptorCommon + + EntityID string `xml:"entityID,attr"` + + AffiliationDescriptor *AffiliationDescriptor + Organization *Organization + ContactPerson *ContactPerson + AdditionalMetadataLocation []string +} + +// Organization specifies basic information about an organization responsible for a SAML +// entity or role. +// See 2.3.2.1 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf +type Organization struct { + Extensions []*etree.Element + OrganizationName []Localized + OrganizationDisplayName []Localized + OrganizationURL []Localized +} + +// ContactPerson specifies basic contact information about a person responsible in some +// capacity for a SAML entity or role. +// See 2.3.2.2 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf +type ContactPerson struct { + ContactType ContactType `xml:",attr"` + Extensions []*etree.Element + Company string + GivenName string + SurName string + EmailAddress []string + TelephoneNumber []string +} + +// RoleDescriptor is an abstract extension point that contains common descriptive +// information intended to provide processing commonality across different roles. +// See 2.4.1 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf +type RoleDescriptor struct { + DescriptorCommon + + ProtocolSupportEnumeration ProtocolSupportEnumeration `xml:"protocolSupportEnumeration,attr,omitempty"` + ErrorURL string `xml:"errorURL,attr,omitempty"` + KeyDescriptor []KeyDescriptor + Organization *Organization + ContactPerson []ContactPerson +} + +// KeyDescriptor provides information about the cryptographic key(s) that an entity uses +// to sign data or receive encrypted keys, along with additional cryptographic details. +// See 2.4.1.1 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf +type KeyDescriptor struct { + Use KeyType `xml:"use,attr"` + KeyInfo KeyInfo + EncryptionMethod []EncryptionMethod +} + +// KeyInfo directly or indireclty identifies a key. It defines the usage of the +// XML Signature element. +// See https://www.w3.org/TR/xmldsig-core1/#sec-KeyInfo +type KeyInfo struct { + dsig.KeyInfo + KeyName string +} + +// EncyrptionMethod describes the encryption algorithm applied to the cipher data. +// See https://www.w3.org/TR/2002/REC-xmlenc-core-20021210/Overview.html#sec-EncryptionMethod +type EncryptionMethod struct { + Algorithm string `xml:"Algorithm,attr"` +} + +// SSODescriptor is the common base type for concrete types such as +// IDPSSODescriptor and SPSSODescriptor. +// See 2.4.2 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf +type SSODescriptor struct { + RoleDescriptor + + ArtifactResolutionService []IndexedEndpoint + SingleLogoutService []Endpoint + ManageNameIDService []Endpoint + NameIDFormat []core.NameIDFormat +} + +// AuthnAuthorityDescriptor ... ??? TODO +// See 2.4.5 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf +type AuthnAuthorityDescriptor struct { + RoleDescriptor + + AuthnQueryService []Endpoint + AssertionIDRequestService []Endpoint + NameIDFormats []core.NameIDFormat +} + +type PDPDescriptor struct{} + +// AttributeAuthorityDescriptor is a compatibiity requirement +// for supporting legacy or other SPs that rely on queries for +// attributes. +type AttributeAuthorityDescriptor struct{} + +// AffiliationDescriptor represents a group of other +// entities, such as related service providers that +// share a persistent NameID. +type AffiliationDescriptor struct{} + +// X509Data contains one ore more identifiers of keys or X509 certifactes. +// See https://www.w3.org/TR/xmldsig-core1/#sec-X509Data +// type X509Data struct { +// XMLName xml.Name `xml:"http://www.w3.org/2000/09/xmldsig# X509Certificate"` +// Data string `xml:",chardata"` +// } diff --git a/saml/models/metadata/idp_sso_descriptor.go b/saml/models/metadata/idp_sso_descriptor.go new file mode 100644 index 00000000..74b4dbb1 --- /dev/null +++ b/saml/models/metadata/idp_sso_descriptor.go @@ -0,0 +1,46 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package metadata + +import ( + "encoding/xml" + + "github.com/hashicorp/cap/saml/models/core" +) + +// IDPSSODescriptor contains profiles specific to identity providers supporting SSO. +// It extends the SSODescriptor type. +// See 2.4.3 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf +type IDPSSODescriptor struct { + XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:metadata IDPSSODescriptor"` + + SSODescriptor + + WantAuthnRequestsSigned bool `xml:",attr"` + SingleSignOnService []Endpoint + NameIDMappingService []Endpoint // TODO test missing! + AssertionIDRequestService []Endpoint // TODO test missing! + AttributeProfile []string // TODO test missing! + Attribute []Attribute +} + +// EntityDescriptorIDPSSO is an EntityDescriptor that accommodates the IDPSSODescriptor +// as descriptor field only. +type EntityDescriptorIDPSSO struct { + EntityDescriptor + + IDPSSODescriptor []*IDPSSODescriptor +} + +func (e *EntityDescriptorIDPSSO) GetLocationForBinding(b core.ServiceBinding) (string, bool) { + for _, isd := range e.IDPSSODescriptor { + for _, ssos := range isd.SingleSignOnService { + if ssos.Binding == b { + return ssos.Location, true + } + } + } + + return "", false +} diff --git a/saml/models/metadata/idp_sso_descriptor_test.go b/saml/models/metadata/idp_sso_descriptor_test.go new file mode 100644 index 00000000..e3ce3a78 --- /dev/null +++ b/saml/models/metadata/idp_sso_descriptor_test.go @@ -0,0 +1,227 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package metadata_test + +import ( + "encoding/xml" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/cap/saml/models/core" + "github.com/hashicorp/cap/saml/models/metadata" +) + +var exampleIDPSSODescriptorX = ` + + ... + + + + IdentityProvider.com AA Key + + + + + urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName + urn:oasis:names:tc:SAML:2.0:nameid-format:persistent + urn:oasis:names:tc:SAML:2.0:nameid-format:transient + + + member + student + faculty + employee + staff + + + + Identity Providers R US + Identity Providers R US, a Division of Lerxst Corp. + https://IdentityProvider.com + +` + +var exampleIDPSSODescriptor = ` + + + +` + +func Test_IDPSSODescriptor(t *testing.T) { + t.Parallel() + r := require.New(t) + + ed := &metadata.EntityDescriptorIDPSSO{} + + err := xml.Unmarshal([]byte(exampleIDPSSODescriptor), ed) + r.NoError(err) + + r.Len(ed.IDPSSODescriptor, 1) + + idp := ed.IDPSSODescriptor[0] + + r.True(idp.WantAuthnRequestsSigned) + r.Equal(idp.ProtocolSupportEnumeration, metadata.ProtocolSupportEnumerationProtocol) +} + +var exampleIDPSSOKeyDescriptor = ` + + + + + IdentityProvider.com SSO Key + + + +` + +func Test_IDPSSODescriptor_KeyDescriptor(t *testing.T) { + t.Parallel() + r := require.New(t) + + ed := &metadata.EntityDescriptorIDPSSO{} + + err := xml.Unmarshal([]byte(exampleIDPSSOKeyDescriptor), ed) + r.NoError(err) + + r.Len(ed.IDPSSODescriptor, 1) + + idp := ed.IDPSSODescriptor[0] + + r.Len(idp.KeyDescriptor, 1) + r.Equal(idp.KeyDescriptor[0].Use, metadata.KeyTypeSigning) + r.Equal(idp.KeyDescriptor[0].KeyInfo.KeyName, "IdentityProvider.com SSO Key") +} + +var exampleIDPSSODescriptorArtifactResolutionService = ` + + + + +` + +func Test_IDPSSODescriptor_ArtifactResolutionService(t *testing.T) { + t.Parallel() + r := require.New(t) + + ed := &metadata.EntityDescriptorIDPSSO{} + + err := xml.Unmarshal([]byte(exampleIDPSSODescriptorArtifactResolutionService), ed) + r.NoError(err) + + r.Len(ed.IDPSSODescriptor, 1) + + ars := ed.IDPSSODescriptor[0].ArtifactResolutionService + + r.Len(ars, 1) + + r.True(ars[0].IsDefault) + r.Equal(ars[0].Index, 0) + r.Equal(ars[0].Binding, core.ServiceBindingSOAP) + r.Equal(ars[0].Location, "https://hashicorp-idp.com/SAML/Artifact") +} + +var exampleIDPSSODescriptorSLO = ` + + + + + +` + +func Test_IDPSSODescriptor_SLO(t *testing.T) { + t.Parallel() + r := require.New(t) + + ed := &metadata.EntityDescriptorIDPSSO{} + + err := xml.Unmarshal([]byte(exampleIDPSSODescriptorSLO), ed) + r.NoError(err) + + r.Len(ed.IDPSSODescriptor, 1) + + slo := ed.IDPSSODescriptor[0].SingleLogoutService + + r.Len(slo, 2) + + r.Equal(slo[0].Binding, core.ServiceBindingSOAP) + r.Equal(slo[0].Location, "https://hashicorp.com/SAML/SLO/SOAP") + + r.Equal(slo[1].Binding, core.ServiceBindingHTTPRedirect) + r.Equal(slo[1].Location, "https://hashicorp.com/SAML/SLO/Browser") +} + +var exampleIDPSSODescriptorSSO = ` + + + + + +` + +func Test_IDPSSODescriptor_SSO(t *testing.T) { + t.Parallel() + r := require.New(t) + + ed := &metadata.EntityDescriptorIDPSSO{} + + err := xml.Unmarshal([]byte(exampleIDPSSODescriptorSSO), ed) + r.NoError(err) + + r.Len(ed.IDPSSODescriptor, 1) + + sso := ed.IDPSSODescriptor[0].SingleSignOnService + + r.Len(sso, 2) + + r.Equal(sso[0].Binding, core.ServiceBindingHTTPRedirect) + r.Equal(sso[0].Location, "https://hashicorp.com/SAML/SSO/Browser") + + r.Equal(sso[1].Binding, core.ServiceBindingHTTPPost) + r.Equal(sso[1].Location, "https://hashicorp.com/SAML/SSO/Browser") +} + +var exampleIDPSSODescriptorAttributes = ` + + + + + member + student + faculty + employee + staff + + +` + +func Test_IDPSSODescriptor_Attributes(t *testing.T) { + t.Parallel() + r := require.New(t) + + ed := &metadata.EntityDescriptorIDPSSO{} + + err := xml.Unmarshal([]byte(exampleIDPSSODescriptorAttributes), ed) + r.NoError(err) + + r.Len(ed.IDPSSODescriptor, 1) + + attr := ed.IDPSSODescriptor[0].Attribute + + r.Len(attr, 2) + + r.Equal(attr[0].NameFormat, string(core.NameFormatURI)) + r.Equal(attr[0].Name, "urn:oid:1.3.6.1.4.1.5923.1.1.1.6") + + r.Equal(attr[1].NameFormat, string(core.NameFormatURI)) + r.Equal(attr[1].Name, "urn:oid:1.3.6.1.4.1.5923.1.1.1.1") + + r.Len(attr[1].AttributeValue, 5) + r.Equal(attr[1].AttributeValue[0].Value, "member") + r.Equal(attr[1].AttributeValue[1].Value, "student") + r.Equal(attr[1].AttributeValue[2].Value, "faculty") + r.Equal(attr[1].AttributeValue[3].Value, "employee") + r.Equal(attr[1].AttributeValue[4].Value, "staff") +} diff --git a/saml/models/metadata/sp_sso_descriptor.go b/saml/models/metadata/sp_sso_descriptor.go new file mode 100644 index 00000000..61395541 --- /dev/null +++ b/saml/models/metadata/sp_sso_descriptor.go @@ -0,0 +1,75 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package metadata + +import "encoding/xml" + +// EntityDescriptorSPSSO defines an EntityDescriptor type +// that can accommodate an SPSSODescriptor. +// This type can be usued specifically to describe SPSSO profiles. +type EntityDescriptorSPSSO struct { + XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:metadata EntityDescriptor"` + + EntityDescriptor + + SPSSODescriptor []*SPSSODescriptor +} + +// SPSSODescriptor contains profiles specific to service providers. +// It extends the SSODescriptor type. +// See 2.4.4 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf +type SPSSODescriptor struct { + XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:metadata SPSSODescriptor"` + + SSODescriptor + + AuthnRequestsSigned bool `xml:",attr"` + WantAssertionsSigned bool `xml:",attr"` + AssertionConsumerService []IndexedEndpoint + AttributeConsumingService []*AttributeConsumingService + Attribute []Attribute +} + +// AttributeConsumingService (ACS) is the location where an IdP will eventually send +// the user at the SP. +// See 2.4.4.1 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf +type AttributeConsumingService struct { + Index int `xml:",attr"` + IsDefault bool `xml:"isDefault,attr"` + ServiceName []Localized + ServiceDescription []Localized + RequestedAttribute []RequestedAttribute +} + +// RequestedAttribute specifies a service providers interest in a specific +// SAML attribute, including specific values. +// See 2.4.4.2 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf +type RequestedAttribute struct { + Attribute + IsRequired bool `xml:"isRequired,attr"` +} + +// TODO: CORE This needs to be part of core? +type Attribute struct { + FriendlyName string `xml:",attr"` + Name string `xml:",attr"` + NameFormat string `xml:",attr"` + AttributeValue []AttributeValue +} + +// TODO: CORE +type AttributeValue struct { + Type string `xml:"http://www.w3.org/2001/XMLSchema-instance type,attr"` + Value string `xml:",chardata"` + NameID *NameID +} + +// TODO: CORE +type NameID struct { + NameQualifier string `xml:",attr"` + SPNameQualifier string `xml:",attr"` + Format string `xml:",attr"` + SPProvidedID string `xml:",attr"` + Value string `xml:",chardata"` +} diff --git a/saml/models/metadata/sp_sso_descriptor_test.go b/saml/models/metadata/sp_sso_descriptor_test.go new file mode 100644 index 00000000..0bab852c --- /dev/null +++ b/saml/models/metadata/sp_sso_descriptor_test.go @@ -0,0 +1,343 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package metadata_test + +import ( + "encoding/xml" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/cap/saml/models/core" + "github.com/hashicorp/cap/saml/models/metadata" +) + +var exampleSPSSODescriptorA = ` + + signature + + + + ServiceProvider.com SSO Key + + + + + ServiceProvider.com Encrypt Key + + + + + + urn:oasis:names:tc:SAML:2.0:nameid-format:transient + + + + Academic Journals R US + + https://ServiceProvider.com/entitlements/123456789 + + + + + Academic Journals R US + Academic Journals R US, a Division of Dirk Corp. + https://ServiceProvider.com + +` + +var exampleSPSSODescriptor = ` + + +` + +func Test_SPSSODescriptor(t *testing.T) { + t.Parallel() + r := require.New(t) + + ed := &metadata.EntityDescriptorSPSSO{} + + err := xml.Unmarshal([]byte(exampleSPSSODescriptor), ed) + r.NoError(err) + + r.Len(ed.SPSSODescriptor, 1) + + spSSO := ed.SPSSODescriptor[0] + + r.True(spSSO.AuthnRequestsSigned) + r.True(spSSO.WantAssertionsSigned) + r.Equal(spSSO.ProtocolSupportEnumeration, metadata.ProtocolSupportEnumerationProtocol) +} + +var exampleSLOService = ` + + + + +` + +func Test_SPSSODescriptor_SLOService(t *testing.T) { + t.Parallel() + r := require.New(t) + + ed := &metadata.EntityDescriptorSPSSO{} + + err := xml.Unmarshal([]byte(exampleSLOService), ed) + r.NoError(err) + + slo := ed.SPSSODescriptor[0].SingleLogoutService + + r.Len(slo, 2) + + r.Equal(slo[0].Binding, core.ServiceBindingHTTPRedirect) + r.Equal(slo[0].Location, "https://hashicorp.com/slo/endpoint") + r.Equal(slo[0].ResponseLocation, "https://hashicorp.com/slo/endpoint") + + r.Equal(slo[1].Binding, core.ServiceBindingSOAP) + r.Equal(slo[1].Location, "https://hashicorp.com/slo/endpoint") + r.Equal(slo[1].ResponseLocation, "") +} + +var exampleNameIDService = ` + + + + +` + +func Test_SPSSODescriptor_ManageNameIDService(t *testing.T) { + t.Parallel() + r := require.New(t) + + ed := &metadata.EntityDescriptorSPSSO{} + + err := xml.Unmarshal([]byte(exampleNameIDService), ed) + r.NoError(err) + + nameIDSvc := ed.SPSSODescriptor[0].ManageNameIDService + + r.Len(nameIDSvc, 2) + + r.Equal(nameIDSvc[0].Binding, core.ServiceBindingHTTPRedirect) + r.Equal(nameIDSvc[0].Location, "https://hashicorp.com/nameid/endpoint") + r.Equal(nameIDSvc[0].ResponseLocation, "https://hashicorp.com/nameid/endpoint") + + r.Equal(nameIDSvc[1].Binding, core.ServiceBindingSOAP) + r.Equal(nameIDSvc[1].Location, "https://hashicorp.com/nameid/endpoint") + r.Equal(nameIDSvc[1].ResponseLocation, "https://hashicorp.com/nameid/endpoint") +} + +var exampleNameIDFormats = ` + + urn:oasis:names:tc:SAML:2.0:nameid-format:persistent + urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + urn:oasis:names:tc:SAML:2.0:nameid-format:transient + +` + +func Test_SPSSODescriptor_NameIDFormats(t *testing.T) { + t.Parallel() + r := require.New(t) + + ed := &metadata.EntityDescriptorSPSSO{} + + err := xml.Unmarshal([]byte(exampleNameIDFormats), ed) + r.NoError(err) + + nameIDFormats := ed.SPSSODescriptor[0].NameIDFormat + + r.Len(nameIDFormats, 3) + + r.Equal(nameIDFormats[0], core.NameIDFormatPersistent) + r.Equal(nameIDFormats[1], core.NameIDFormatEmail) + r.Equal(nameIDFormats[2], core.NameIDFormatTransient) +} + +var exampleACS = ` + + + + +` + +func Test_SPSSODescriptor_ACS(t *testing.T) { + t.Parallel() + r := require.New(t) + + ed := &metadata.EntityDescriptorSPSSO{} + + err := xml.Unmarshal([]byte(exampleACS), ed) + r.NoError(err) + + acs := ed.SPSSODescriptor[0].AssertionConsumerService + + r.Len(acs, 2) + + r.True(acs[0].IsDefault) + r.Equal(acs[0].Binding, core.ServiceBindingHTTPRedirect) + r.Equal(acs[0].Index, 0) + r.Equal(acs[0].Location, "https://hashicorp.com/acs/endpoint") + + r.False(acs[1].IsDefault) + r.Equal(acs[1].Binding, core.ServiceBindingHTTPPost) + r.Equal(acs[1].Index, 1) + r.Equal(acs[1].Location, "https://hashicorp.com/acs/endpoint") +} + +var exampleAttributeConsumingService = ` + + + Academic Journals R US + Wir sind Akademische Zeitungen + + https://hashicorp.com/entitlements/123456789 + + + + Academic Journals R US + + https://hashicorp.com/entitlements/987654321 + + + +` + +// TODO: Check on Attributes & AttributeValues +// +// By-Tor +// +// By-Tor + +func Test_SPSSODescriptor_AttributeConsumingService(t *testing.T) { + t.Parallel() + r := require.New(t) + + ed := &metadata.EntityDescriptorSPSSO{} + + err := xml.Unmarshal([]byte(exampleAttributeConsumingService), ed) + r.NoError(err) + + acs := ed.SPSSODescriptor[0].AttributeConsumingService + + r.Len(acs, 2) + + r.Equal(acs[0].Index, 0) + r.True(acs[0].IsDefault) + + r.Equal(acs[0].ServiceName[0].Lang, "en") + r.Equal(acs[0].ServiceName[0].Value, "Academic Journals R US") + r.Equal(acs[0].ServiceName[1].Lang, "de") + r.Equal(acs[0].ServiceName[1].Value, "Wir sind Akademische Zeitungen") + + r.Equal(acs[0].RequestedAttribute[0].Name, "urn:oid:1.3.6.1.4.1.5923.1.1.1.7") + r.Equal(acs[0].RequestedAttribute[0].FriendlyName, "eduPersonEntitlement") + r.Equal(acs[0].RequestedAttribute[0].NameFormat, "urn:oasis:names:tc:SAML:2.0:attrname-format:uri") + r.True(acs[0].RequestedAttribute[0].IsRequired) + r.Len(acs[0].RequestedAttribute[0].AttributeValue, 1) + r.Equal(acs[0].RequestedAttribute[0].AttributeValue[0].Value, "https://hashicorp.com/entitlements/123456789") + + r.Equal(acs[1].ServiceName[0].Lang, "en") + r.Equal(acs[1].ServiceName[0].Value, "Academic Journals R US") + + r.Equal(acs[1].RequestedAttribute[0].Name, "urn:oid:1.3.6.1.4.1.5923.1.1.1.8") + r.Equal(acs[1].RequestedAttribute[0].FriendlyName, "eduPersonEntitlement") + r.Equal(acs[1].RequestedAttribute[0].NameFormat, "urn:oasis:names:tc:SAML:2.0:attrname-format:uri") + r.Len(acs[1].RequestedAttribute[0].AttributeValue, 1) + r.Equal(acs[1].RequestedAttribute[0].AttributeValue[0].Value, "https://hashicorp.com/entitlements/987654321") +} + +var exampleKeyDescriptor = ` + + + + + +MIICYDCCAgqgAwIBAgICBoowDQYJKoZIhvcNAQEEBQAwgZIxCzAJBgNVBAYTAlVTMRMwEQYDVQQI +EwpDYWxpZm9ybmlhMRQwEgYDVQQHEwtTYW50YSBDbGFyYTEeMBwGA1UEChMVU3VuIE1pY3Jvc3lz +dGVtcyBJbmMuMRowGAYDVQQLExFJZGVudGl0eSBTZXJ2aWNlczEcMBoGA1UEAxMTQ2VydGlmaWNh +dGUgTWFuYWdlcjAeFw0wNjExMDIxOTExMzRaFw0xMDA3MjkxOTExMzRaMDcxEjAQBgNVBAoTCXNp +cm9lLmNvbTEhMB8GA1UEAxMYbG9hZGJhbGFuY2VyLTkuc2lyb2UuY29tMIGfMA0GCSqGSIb3DQEB +AQUAA4GNADCBiQKBgQCjOwa5qoaUuVnknqf5pdgAJSEoWlvx/jnUYbkSDpXLzraEiy2UhvwpoBgB +EeTSUaPPBvboCItchakPI6Z/aFdH3Wmjuij9XD8r1C+q//7sUO0IGn0ORycddHhoo0aSdnnxGf9V +tREaqKm9dJ7Yn7kQHjo2eryMgYxtr/Z5Il5F+wIDAQABo2AwXjARBglghkgBhvhCAQEEBAMCBkAw +DgYDVR0PAQH/BAQDAgTwMB8GA1UdIwQYMBaAFDugITflTCfsWyNLTXDl7cMDUKuuMBgGA1UdEQQR +MA+BDW1hbGxhQHN1bi5jb20wDQYJKoZIhvcNAQEEBQADQQB/6DOB6sRqCZu2OenM9eQR0gube85e +nTTxU4a7x1naFxzYXK1iQ1vMARKMjDb19QEJIEJKZlDK4uS7yMlf1nFS + + + + + + + + +MIICTDCCAfagAwIBAgICBo8wDQYJKoZIhvcNAQEEBQAwgZIxCzAJBgNVBAYTAlVTMRMwEQYDVQQI +EwpDYWxpZm9ybmlhMRQwEgYDVQQHEwtTYW50YSBDbGFyYTEeMBwGA1UEChMVU3VuIE1pY3Jvc3lz +dGVtcyBJbmMuMRowGAYDVQQLExFJZGVudGl0eSBTZXJ2aWNlczEcMBoGA1UEAxMTQ2VydGlmaWNh +dGUgTWFuYWdlcjAeFw0wNjExMDcyMzU2MTdaFw0xMDA4MDMyMzU2MTdaMCMxITAfBgNVBAMTGGxv +YWRiYWxhbmNlci05LnNpcm9lLmNvbTCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAw574iRU6 +HsSO4LXW/OGTXyfsbGv6XRVOoy3v+J1pZ51KKejcDjDJXNkKGn3/356AwIaqbcymWd59T0zSqYfR +Hn+45uyjYxRBmVJseLpVnOXLub9jsjULfGx0yjH4w+KsZSZCXatoCHbj/RJtkzuZY6V9to/hkH3S +InQB4a3UAgMCAwEAAaNgMF4wEQYJYIZIAYb4QgEBBAQDAgZAMA4GA1UdDwEB/wQEAwIE8DAfBgNV +HSMEGDAWgBQ7oCE35Uwn7FsjS01w5e3DA1CrrjAYBgNVHREEETAPgQ1tYWxsYUBzdW4uY29tMA0G +CSqGSIb3DQEBBAUAA0EAMlbfBg/ff0Xkv4DOR5LEqmfTZKqgdlD81cXynfzlF7XfnOqI6hPIA90I +x5Ql0ejivIJAYcMGUyA+/YwJg2FGoA== + + + + + 128 + + + +` diff --git a/saml/options.go b/saml/options.go new file mode 100644 index 00000000..64838313 --- /dev/null +++ b/saml/options.go @@ -0,0 +1,19 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package saml + +// Option defines a common functional options type which can be used in a +// variadic parameter pattern. +type Option func(interface{}) + +// ApplyOpts takes a pointer to the options struct as a set of default options +// and applies the slice of opts as overrides. +func ApplyOpts(opts interface{}, opt ...Option) { + for _, o := range opt { + if o == nil { // ignore any nil Options + continue + } + o(opts) + } +} diff --git a/saml/response.go b/saml/response.go new file mode 100644 index 00000000..894d0e8f --- /dev/null +++ b/saml/response.go @@ -0,0 +1,247 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package saml + +import ( + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "regexp" + + "github.com/jonboulle/clockwork" + saml2 "github.com/russellhaering/gosaml2" + dsig "github.com/russellhaering/goxmldsig" + + "github.com/hashicorp/cap/saml/models/core" + "github.com/hashicorp/cap/saml/models/metadata" +) + +type parseResponseOptions struct { + clock clockwork.Clock + skipRequestIDValidation bool + skipAssertionConditionValidation bool + skipSignatureValidation bool + assertionConsumerServiceURL string +} + +func parseResponseOptionsDefault() parseResponseOptions { + return parseResponseOptions{ + clock: clockwork.NewRealClock(), + skipRequestIDValidation: false, + skipAssertionConditionValidation: false, + skipSignatureValidation: false, + } +} + +func getParseResponseOptions(opt ...Option) parseResponseOptions { + opts := parseResponseOptionsDefault() + ApplyOpts(&opts, opt...) + return opts +} + +// InsecureSkipRequestIDValidation disables/skips if the given requestID matches +// the InResponseTo parameter in the SAML response. This options should only +// be used for testing purposes. +func InsecureSkipRequestIDValidation() Option { + return func(o interface{}) { + if o, ok := o.(*parseResponseOptions); ok { + o.skipRequestIDValidation = true + } + } +} + +// InsecureSkipAssertionConditionValidation disables/skips validation of the assertion +// conditions within the SAML response. This options should only be used for +// testing purposes. +func InsecureSkipAssertionConditionValidation() Option { + return func(o interface{}) { + if o, ok := o.(*parseResponseOptions); ok { + o.skipAssertionConditionValidation = true + } + } +} + +// InsecureSkipSignatureValidation disables/skips validation of the SAML Response and its assertions. +// This options should only be used for testing purposes. +func InsecureSkipSignatureValidation() Option { + return func(o interface{}) { + if o, ok := o.(*parseResponseOptions); ok { + o.skipSignatureValidation = true + } + } +} + +// ParseResponse parses and validates a SAML Reponse. +// +// Options: +// - InsecureSkipRequestIDValidation +// - InsecureSkipAssertionConditionValidation +// - InsecureSkipSignatureValidation +// - WithAssertionConsumerServiceURL +// - WithClock +func (sp *ServiceProvider) ParseResponse( + samlResp string, + requestID string, + opt ...Option, +) (*core.Response, error) { + const op = "saml.(ServiceProvider).ParseResponse" + switch { + case sp == nil: + return nil, fmt.Errorf("%s: missing service provider %w", op, ErrInternal) + case samlResp == "": + return nil, fmt.Errorf("%s: missing saml response: %w", op, ErrInvalidParameter) + case requestID == "": + return nil, fmt.Errorf("%s: missing request ID: %w", op, ErrInvalidParameter) + } + opts := getParseResponseOptions(opt...) + + // We use github.com/russellhaering/gosaml2 for SAMLResponse signature and condition validation. + ip, err := sp.internalParser( + opts.skipSignatureValidation, + opts.assertionConsumerServiceURL, + opts.clock, + ) + if err != nil { + return nil, fmt.Errorf("%s: error initializing parser: %w", op, err) + } + + // This will validate the response and all assertions. + response, err := ip.ValidateEncodedResponse(samlResp) + switch { + case err != nil: + return nil, fmt.Errorf("%s: unable to validate encoded response: %w", op, err) + case len(response.Assertions) == 0: + // note: this is currently unreachable since the call to + // ip.ValidateEncodedResponse(...) above will return an err if there are + // no assertions, but we've left this here since it's a required for our + // implementation as well. + return nil, fmt.Errorf("%s: %w", op, ErrMissingAssertions) + case !opts.skipRequestIDValidation && response.InResponseTo != requestID: + return nil, fmt.Errorf( + "InResponseTo (%s) doesn't match the expected requestID (%s)", + response.InResponseTo, + requestID, + ) + case !opts.skipAssertionConditionValidation: + // Verify conditions for all assertions + for _, assert := range response.Assertions { + warnings, err := ip.VerifyAssertionConditions(&assert) + switch { + case err != nil: + return nil, fmt.Errorf("%s: %w", op, err) + case warnings.InvalidTime: + // note: this is currently unreachable since the call to + // ip.ValidateEncodedResponse(...) above will return an err if + // the time is invalid, but we've left this here since it's a + // required for our implementation as well. + return nil, fmt.Errorf("%s: %w", op, ErrInvalidTime) + case warnings.NotInAudience: + return nil, fmt.Errorf("%s: %w", op, ErrInvalidAudience) + case assert.Subject == nil || assert.Subject.NameID == nil: + // note: this is currently unreachable since the call to + // ip.ValidateEncodedResponse(...) above will return an err if + // there isn't a subject, but we've left this here since it's a + // required for our implementation as well. + return nil, fmt.Errorf("%s: %w", op, ErrMissingSubject) + case assert.AttributeStatement == nil: + return nil, fmt.Errorf("%s: %w", op, ErrMissingAttributeStmt) + } + } + } + + return &core.Response{Response: *response}, nil +} + +func (sp *ServiceProvider) internalParser( + skipSignatureValidation bool, + assertionConsumerServiceURL string, + clock clockwork.Clock, +) (*saml2.SAMLServiceProvider, error) { + const op = "saml.(ServiceProvider).internalParser" + switch { + case isNil(clock): + return nil, fmt.Errorf("%s: missing clock: %w", op, ErrInvalidParameter) + } + idpMetadata, err := sp.IDPMetadata() + if err != nil { + return nil, fmt.Errorf("%s: %w", op, err) + } + switch { + case err != nil: + return nil, fmt.Errorf("%s: %w", op, err) + case len(idpMetadata.IDPSSODescriptor) != 1: + return nil, fmt.Errorf("%s: expected one IdP descriptor and got %d: %w", op, len(idpMetadata.IDPSSODescriptor), ErrInternal) + } + + var certStore dsig.MemoryX509CertificateStore + for _, kd := range idpMetadata.IDPSSODescriptor[0].KeyDescriptor { + switch kd.Use { + case "", metadata.KeyTypeSigning: + for _, xcert := range kd.KeyInfo.X509Data.X509Certificates { + parsed, err := parseX509Certificate(xcert.Data) + if err != nil { + return nil, fmt.Errorf("%s: unable to parse cert: %w", op, err) + } + certStore.Roots = append(certStore.Roots, parsed) // append works just fine with a nil slice + } + } + } + + if assertionConsumerServiceURL == "" { + assertionConsumerServiceURL = sp.cfg.AssertionConsumerServiceURL + } + + return &saml2.SAMLServiceProvider{ + IdentityProviderIssuer: idpMetadata.EntityID, + IDPCertificateStore: &certStore, + ServiceProviderIssuer: sp.cfg.EntityID, + AudienceURI: sp.cfg.EntityID, + AssertionConsumerServiceURL: assertionConsumerServiceURL, + SkipSignatureValidation: skipSignatureValidation, + Clock: dsig.NewFakeClock(clock), + }, nil +} + +// parseX509Certificate parses the contents of a which is a +// base64-encoded ASN.1 DER certificate. It does not parse PEM-encoded certificates. +func parseX509Certificate(cert string) (*x509.Certificate, error) { + const op = "saml.parseCert" + switch { + case cert == "": + return nil, fmt.Errorf("%s: missing certificate: %w", op, ErrInvalidParameter) + default: + regex := regexp.MustCompile(`\s+`) + cert = regex.ReplaceAllString(cert, "") + if cert == "" { + return nil, fmt.Errorf("%s: certificate was only whitespace: %w", op, ErrInvalidParameter) + } + } + certBytes, err := base64.StdEncoding.DecodeString(cert) + if err != nil { + return nil, fmt.Errorf("cannot decode certificate: %s", err) + } + parsedCert, err := x509.ParseCertificate(certBytes) + if err != nil { + return nil, fmt.Errorf("cannot parse certificate: %s", err) + } + + return parsedCert, nil +} + +func parsePEMCertificate(cert []byte) (*x509.Certificate, error) { + block, rest := pem.Decode(cert) + if block == nil { + return nil, fmt.Errorf("no certificate found") + } + if len(rest) != 0 { + return nil, fmt.Errorf("extra data found after certificate: %s", rest) + } + + if block.Type != "CERTIFICATE" { + return nil, fmt.Errorf("wrong block type found: %q", block.Type) + } + + return x509.ParseCertificate(block.Bytes) +} diff --git a/saml/response_test.go b/saml/response_test.go new file mode 100644 index 00000000..574b38bd --- /dev/null +++ b/saml/response_test.go @@ -0,0 +1,751 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package saml_test + +import ( + "encoding/base64" + "fmt" + "testing" + "time" + + "github.com/hashicorp/cap/saml" + "github.com/hashicorp/cap/saml/models/core" + testprovider "github.com/hashicorp/cap/saml/test" + "github.com/jonboulle/clockwork" + saml2 "github.com/russellhaering/gosaml2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var testExpiredResp = `PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHNhbWwycDpSZXNwb25zZSBEZXN0aW5hdGlvbj0iaHR0cDovL2xvY2FsaG9zdDo4MDAwL3NhbWwvYWNzIiBJRD0iXzg4NDljMmVlNTMyZmNkYjc4MWYyYTE3NzZlYWMzNzQxIiBJblJlc3BvbnNlVG89ImJjNWE1YmFhLTk0ZTAtNThhOC04NzJjLWU1MTQ5MWQyYjNlZSIgSXNzdWVJbnN0YW50PSIyMDIzLTA4LTI1VDE0OjMyOjUzLjY4MFoiIFZlcnNpb249IjIuMCIgeG1sbnM6c2FtbDJwPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6cHJvdG9jb2wiIHhtbG5zOnhzZD0iaHR0cDovL3d3dy53My5vcmcvMjAwMS9YTUxTY2hlbWEiPjxzYW1sMjpJc3N1ZXIgeG1sbnM6c2FtbDI9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphc3NlcnRpb24iPmh0dHBzOi8vc2FtbHRlc3QuaWQvc2FtbC9pZHA8L3NhbWwyOklzc3Vlcj48ZHM6U2lnbmF0dXJlIHhtbG5zOmRzPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwLzA5L3htbGRzaWcjIj48ZHM6U2lnbmVkSW5mbz48ZHM6Q2Fub25pY2FsaXphdGlvbk1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMTAveG1sLWV4Yy1jMTRuIyIvPjxkczpTaWduYXR1cmVNZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGRzaWctbW9yZSNyc2Etc2hhMjU2Ii8+PGRzOlJlZmVyZW5jZSBVUkk9IiNfODg0OWMyZWU1MzJmY2RiNzgxZjJhMTc3NmVhYzM3NDEiPjxkczpUcmFuc2Zvcm1zPjxkczpUcmFuc2Zvcm0gQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwLzA5L3htbGRzaWcjZW52ZWxvcGVkLXNpZ25hdHVyZSIvPjxkczpUcmFuc2Zvcm0gQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzEwL3htbC1leGMtYzE0biMiPjxlYzpJbmNsdXNpdmVOYW1lc3BhY2VzIFByZWZpeExpc3Q9InhzZCIgeG1sbnM6ZWM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMTAveG1sLWV4Yy1jMTRuIyIvPjwvZHM6VHJhbnNmb3JtPjwvZHM6VHJhbnNmb3Jtcz48ZHM6RGlnZXN0TWV0aG9kIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8wNC94bWxlbmMjc2hhMjU2Ii8+PGRzOkRpZ2VzdFZhbHVlPlJWNDg1dUtHSlptTkExbzU2Z3h4aytWWmt2eE1xdGxIWkEyaUhIOFpVMVE9PC9kczpEaWdlc3RWYWx1ZT48L2RzOlJlZmVyZW5jZT48L2RzOlNpZ25lZEluZm8+PGRzOlNpZ25hdHVyZVZhbHVlPmQzTHBjNmhjU0I3YndDek1yTzN3ZlpyTmlHazVnWjhyS1JLT1FFTkRQMnErcDMrTGtEbVNCdDZ6enl4bjMzTUNTSnQrZFBIcEYxNFlNQUsvTjNQbld3U1NVcDBqNWt6T2M5S2E1TmRpYW5FME5nWW5VMHFqaEZKYlRoQVF6N2hSb3dTNEo0OWhTLzZNdVNRMFo3bkJCQ2VEZ2VENlBZUkFwS012bE90a0JHUEphTFQybVJ5L2duUStDQzZ1ZFVkSnl2U2diOW40M2x2eGRhYVpXckRLM1dnYTk4WWxrY1JITHJtUEFBTThLeFlXbmtvcGlvNllJTlU0RDVtWmpzRXNuVWtINDFXZ2N3Z21TMnh6UDNJQ25OYzNXSDlOSHJWS3A5YXQyREJ3cllESXNlczZGWGdZcStpVVdLMjE5MWpXcElDM3FWQUIwY09pbG1SWHd0RUg3Zz09PC9kczpTaWduYXR1cmVWYWx1ZT48ZHM6S2V5SW5mbz48ZHM6WDUwOURhdGE+PGRzOlg1MDlDZXJ0aWZpY2F0ZT5NSUlERWpDQ0FmcWdBd0lCQWdJVkFNRUNRMXRqZ2hhZm01T3hXRGg5aHdaZnh0aFdNQTBHQ1NxR1NJYjNEUUVCQ3dVQU1CWXhGREFTCkJnTlZCQU1NQzNOaGJXeDBaWE4wTG1sa01CNFhEVEU0TURneU5ESXhNVFF3T1ZvWERUTTRNRGd5TkRJeE1UUXdPVm93RmpFVU1CSUcKQTFVRUF3d0xjMkZ0YkhSbGMzUXVhV1F3Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLQW9JQkFRQzBaNFFYMU5GSwpzNzF1ZmJRd29Rb1c3cWtOQUpSSUFOR0E0aU0wVGhZZ2h1bDNwQytGd3JHdjM3YVR4V1hmQTFVRzluaktiYkRyZWlEQVpLbmdDZ3lqCnhqMHVKNGxBcmdrcjRBT0VqajV6WEE4MXVHSEFSZlVCY3R2UWNzWnBCSXhET3ZVVUltQWwrM05xTGdNR0YyZmt0eE1HN2tYM0dFVk4KYzFrbGJOM2RmWXNhdzVkVXJ3MjVEaGVMOW5wN0cvKzI4R3dIUHZMYjRhcHRPaU9OYkNhVnZoOVVNSEVBOUY3YzB6ZkYvY0w1Zk9wZApWYTU0d1RJMHUxMkNzRkt0NzhoNmxFR0c1alVzL3FYOWNsWm5jSk03RUZrTjNpbVBQeSswSEM4bnNwWGlIL01aVzhvMmNxV1JrcnczCk16QlpXM09qazVuUWo0MFY2TlViamI3a2ZlanpBZ01CQUFHalZ6QlZNQjBHQTFVZERnUVdCQlFUNlk5SjNUdy9oT0djOFBOVjdKRUUKNGsyWk5UQTBCZ05WSFJFRUxUQXJnZ3R6WVcxc2RHVnpkQzVwWklZY2FIUjBjSE02THk5ellXMXNkR1Z6ZEM1cFpDOXpZVzFzTDJsawpjREFOQmdrcWhraUc5dzBCQVFzRkFBT0NBUUVBU2szZ3VLZlRrVmhFYUlWdnhFUE5SMnczdld0M2Z3bXdKQ2NjVzk4WFhMV2dOYnUzCllhTWIyUlNuN1RoNHAzaCttZnlrMmRvbjZhdTdVeXpjMUpkMzlSTnY4MFRHNWlRb3hmQ2dwaHkxRlltbWRhU2ZPOHd2RHRIVFROaUwKQXJBeE9ZdHpmWWJ6YjVRck5OSC9nUUVOOFJKYUVmL2cvMUdUdzl4LzEwM2RTTUswUlh0bCtmUnMybmJsRDFKSktTUTNBZGh4Sy93ZQpQM2FVUHRMeFZWSjl3TU9RT2ZjeTAybCtoSE1iNnVBanNQT3BPVktxaTNNOFhtY1VaT3B4NHN3dGdHZGVvU3BlUnlydE12UndkY2NpCk5CcDlVWm9tZTQ0cVpBWUgxaXFycG1tanNmSTlwSkl0c2dXdTNrWFBqaFNmajFBSkdSMWw5Skd2SnJIa2kxaUhUQT09PC9kczpYNTA5Q2VydGlmaWNhdGU+PC9kczpYNTA5RGF0YT48L2RzOktleUluZm8+PC9kczpTaWduYXR1cmU+PHNhbWwycDpTdGF0dXM+PHNhbWwycDpTdGF0dXNDb2RlIFZhbHVlPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6c3RhdHVzOlN1Y2Nlc3MiLz48L3NhbWwycDpTdGF0dXM+PHNhbWwyOkFzc2VydGlvbiBJRD0iXzM1ZWE5MGI3MTFkNmYzODUzNDVmMGRiZGQ3ZDBlZDViIiBJc3N1ZUluc3RhbnQ9IjIwMjMtMDgtMjVUMTQ6MzI6NTMuNjgwWiIgVmVyc2lvbj0iMi4wIiB4bWxuczpzYW1sMj0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmFzc2VydGlvbiI+PHNhbWwyOklzc3Vlcj5odHRwczovL3NhbWx0ZXN0LmlkL3NhbWwvaWRwPC9zYW1sMjpJc3N1ZXI+PHNhbWwyOlN1YmplY3Q+PHNhbWwyOk5hbWVJRCBGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjEuMTpuYW1laWQtZm9ybWF0OmVtYWlsQWRkcmVzcyIgTmFtZVF1YWxpZmllcj0iaHR0cHM6Ly9zYW1sdGVzdC5pZC9zYW1sL2lkcCIgU1BOYW1lUXVhbGlmaWVyPSJodHRwOi8vc2FtbC5qdWx6L2V4YW1wbGUiIHhtbG5zOnNhbWwyPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXNzZXJ0aW9uIj5tc21pdGhAc2FtbHRlc3QuaWQ8L3NhbWwyOk5hbWVJRD48c2FtbDI6U3ViamVjdENvbmZpcm1hdGlvbiBNZXRob2Q9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpjbTpiZWFyZXIiPjxzYW1sMjpTdWJqZWN0Q29uZmlybWF0aW9uRGF0YSBBZGRyZXNzPSIxMDQuMjguMzkuMzQiIEluUmVzcG9uc2VUbz0iYmM1YTViYWEtOTRlMC01OGE4LTg3MmMtZTUxNDkxZDJiM2VlIiBOb3RPbk9yQWZ0ZXI9IjIwMjMtMDgtMjVUMTQ6Mzc6NTMuNjkzWiIgUmVjaXBpZW50PSJodHRwOi8vbG9jYWxob3N0OjgwMDAvc2FtbC9hY3MiLz48L3NhbWwyOlN1YmplY3RDb25maXJtYXRpb24+PC9zYW1sMjpTdWJqZWN0PjxzYW1sMjpDb25kaXRpb25zIE5vdEJlZm9yZT0iMjAyMy0wOC0yNVQxNDozMjo1My42ODBaIiBOb3RPbk9yQWZ0ZXI9IjIwMjMtMDgtMjVUMTQ6Mzc6NTMuNjgwWiI+PHNhbWwyOkF1ZGllbmNlUmVzdHJpY3Rpb24+PHNhbWwyOkF1ZGllbmNlPmh0dHA6Ly9zYW1sLmp1bHovZXhhbXBsZTwvc2FtbDI6QXVkaWVuY2U+PC9zYW1sMjpBdWRpZW5jZVJlc3RyaWN0aW9uPjwvc2FtbDI6Q29uZGl0aW9ucz48c2FtbDI6QXV0aG5TdGF0ZW1lbnQgQXV0aG5JbnN0YW50PSIyMDIzLTA4LTI1VDE0OjMxOjU2LjA2NFoiIFNlc3Npb25JbmRleD0iX2Y3MmE2M2VlMzc4MmI0N2M4OWY2MGU4MWFkZGUwYWIwIj48c2FtbDI6U3ViamVjdExvY2FsaXR5IEFkZHJlc3M9IjEwNC4yOC4zOS4zNCIvPjxzYW1sMjpBdXRobkNvbnRleHQ+PHNhbWwyOkF1dGhuQ29udGV4dENsYXNzUmVmPnVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphYzpjbGFzc2VzOlBhc3N3b3JkUHJvdGVjdGVkVHJhbnNwb3J0PC9zYW1sMjpBdXRobkNvbnRleHRDbGFzc1JlZj48L3NhbWwyOkF1dGhuQ29udGV4dD48L3NhbWwyOkF1dGhuU3RhdGVtZW50PjxzYW1sMjpBdHRyaWJ1dGVTdGF0ZW1lbnQ+PHNhbWwyOkF0dHJpYnV0ZSBGcmllbmRseU5hbWU9ImVkdVBlcnNvbkVudGl0bGVtZW50IiBOYW1lPSJ1cm46b2lkOjEuMy42LjEuNC4xLjU5MjMuMS4xLjEuNyIgTmFtZUZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmF0dHJuYW1lLWZvcm1hdDp1cmkiPjxzYW1sMjpBdHRyaWJ1dGVWYWx1ZT5BbWJhc3NhZG9yPC9zYW1sMjpBdHRyaWJ1dGVWYWx1ZT48c2FtbDI6QXR0cmlidXRlVmFsdWU+Tm9uZTwvc2FtbDI6QXR0cmlidXRlVmFsdWU+PC9zYW1sMjpBdHRyaWJ1dGU+PHNhbWwyOkF0dHJpYnV0ZSBOYW1lPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDphdHRyaWJ1dGU6c3ViamVjdC1pZCIgTmFtZUZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmF0dHJuYW1lLWZvcm1hdDp1cmkiPjxzYW1sMjpBdHRyaWJ1dGVWYWx1ZSB4bWxuczp4c2k9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hLWluc3RhbmNlIiB4c2k6dHlwZT0ieHNkOnN0cmluZyI+bXNtaXRoQHNhbWx0ZXN0LmlkPC9zYW1sMjpBdHRyaWJ1dGVWYWx1ZT48L3NhbWwyOkF0dHJpYnV0ZT48c2FtbDI6QXR0cmlidXRlIEZyaWVuZGx5TmFtZT0idWlkIiBOYW1lPSJ1cm46b2lkOjAuOS4yMzQyLjE5MjAwMzAwLjEwMC4xLjEiIE5hbWVGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphdHRybmFtZS1mb3JtYXQ6dXJpIj48c2FtbDI6QXR0cmlidXRlVmFsdWU+bW9ydHk8L3NhbWwyOkF0dHJpYnV0ZVZhbHVlPjwvc2FtbDI6QXR0cmlidXRlPjxzYW1sMjpBdHRyaWJ1dGUgRnJpZW5kbHlOYW1lPSJ0ZWxlcGhvbmVOdW1iZXIiIE5hbWU9InVybjpvaWQ6Mi41LjQuMjAiIE5hbWVGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphdHRybmFtZS1mb3JtYXQ6dXJpIj48c2FtbDI6QXR0cmlidXRlVmFsdWU+KzEtNTU1LTU1NS01NTA1PC9zYW1sMjpBdHRyaWJ1dGVWYWx1ZT48L3NhbWwyOkF0dHJpYnV0ZT48c2FtbDI6QXR0cmlidXRlIEZyaWVuZGx5TmFtZT0icm9sZSIgTmFtZT0iaHR0cHM6Ly9zYW1sdGVzdC5pZC9hdHRyaWJ1dGVzL3JvbGUiIE5hbWVGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphdHRybmFtZS1mb3JtYXQ6dXJpIj48c2FtbDI6QXR0cmlidXRlVmFsdWUgeG1sbnM6eHNpPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeHNpOnR5cGU9InhzZDpzdHJpbmciPmphbml0b3JAc2FtbHRlc3QuaWQ8L3NhbWwyOkF0dHJpYnV0ZVZhbHVlPjwvc2FtbDI6QXR0cmlidXRlPjxzYW1sMjpBdHRyaWJ1dGUgRnJpZW5kbHlOYW1lPSJtYWlsIiBOYW1lPSJ1cm46b2lkOjAuOS4yMzQyLjE5MjAwMzAwLjEwMC4xLjMiIE5hbWVGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphdHRybmFtZS1mb3JtYXQ6dXJpIj48c2FtbDI6QXR0cmlidXRlVmFsdWU+bXNtaXRoQHNhbWx0ZXN0LmlkPC9zYW1sMjpBdHRyaWJ1dGVWYWx1ZT48L3NhbWwyOkF0dHJpYnV0ZT48c2FtbDI6QXR0cmlidXRlIEZyaWVuZGx5TmFtZT0ic24iIE5hbWU9InVybjpvaWQ6Mi41LjQuNCIgTmFtZUZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmF0dHJuYW1lLWZvcm1hdDp1cmkiPjxzYW1sMjpBdHRyaWJ1dGVWYWx1ZT5TbWl0aDwvc2FtbDI6QXR0cmlidXRlVmFsdWU+PC9zYW1sMjpBdHRyaWJ1dGU+PHNhbWwyOkF0dHJpYnV0ZSBGcmllbmRseU5hbWU9ImRpc3BsYXlOYW1lIiBOYW1lPSJ1cm46b2lkOjIuMTYuODQwLjEuMTEzNzMwLjMuMS4yNDEiIE5hbWVGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphdHRybmFtZS1mb3JtYXQ6dXJpIj48c2FtbDI6QXR0cmlidXRlVmFsdWU+TW9ydHkgU21pdGg8L3NhbWwyOkF0dHJpYnV0ZVZhbHVlPjwvc2FtbDI6QXR0cmlidXRlPjxzYW1sMjpBdHRyaWJ1dGUgRnJpZW5kbHlOYW1lPSJnaXZlbk5hbWUiIE5hbWU9InVybjpvaWQ6Mi41LjQuNDIiIE5hbWVGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphdHRybmFtZS1mb3JtYXQ6dXJpIj48c2FtbDI6QXR0cmlidXRlVmFsdWU+TW9ydGltZXI8L3NhbWwyOkF0dHJpYnV0ZVZhbHVlPjwvc2FtbDI6QXR0cmlidXRlPjwvc2FtbDI6QXR0cmlidXRlU3RhdGVtZW50Pjwvc2FtbDI6QXNzZXJ0aW9uPjwvc2FtbDJwOlJlc3BvbnNlPg==` + +// TODO: add the ability to sign requests, so we can write more complete unit tests +func TestServiceProvider_ParseResponse(t *testing.T) { + t.Parallel() + const ( + testRequestId = "bc5a5baa-94e0-58a8-872c-e51491d2b3ee" + testEntityID = "http://saml.julz/example" + testAcs = "http://localhost:8000/saml/acs" + metadataURL = "https://samltest.id/saml/idp" + ) + + testCfg, err := saml.NewConfig(testEntityID, testAcs, metadataURL) + require.NoError(t, err) + testSp, err := saml.NewServiceProvider(testCfg) + require.NoError(t, err) + + fakeTime, err := time.Parse("2006-01-02 15:04:05", "2023-08-25 14:33:53") + require.NoError(t, err) + + testCfgWithBadMetadata, err := saml.NewConfig(testEntityID, testAcs, "https://samltest.id/saml/idp-invalid") + require.NoError(t, err) + testSpWithInvalidMetadataURL, err := saml.NewServiceProvider(testCfgWithBadMetadata) + require.NoError(t, err) + + tests := []struct { + name string + sp *saml.ServiceProvider + samlResp string + requestID string + opts []saml.Option + want *core.Response + wantErrContains string + wantErrIs error + wantErrAs error + }{ + { + name: "success", + sp: testSp, + samlResp: testExpiredResp, + opts: []saml.Option{ + saml.WithClock(clockwork.NewFakeClockAt(fakeTime)), + }, + requestID: testRequestId, + }, + { + name: "err-assertion-missing-attribute-stmt", + sp: testSp, + samlResp: base64.StdEncoding.EncodeToString([]byte(testRespInvalidAssertionMissingAttributeStmt)), + opts: []saml.Option{ + saml.WithClock(clockwork.NewFakeClockAt(fakeTime)), + saml.InsecureSkipSignatureValidation(), + }, + requestID: testRequestId, + wantErrContains: "attribute statement missing", + }, + { + name: "err-assertion-missing-subject", + sp: testSp, + samlResp: base64.StdEncoding.EncodeToString([]byte(testRespInvalidAssertionMissingSubject)), + opts: []saml.Option{ + saml.WithClock(clockwork.NewFakeClockAt(fakeTime)), + saml.InsecureSkipSignatureValidation(), + }, + requestID: testRequestId, + wantErrContains: "unable to validate encoded response: missing Subject element", + }, + { + name: "err-assertion-missing-not-before", + sp: testSp, + samlResp: base64.StdEncoding.EncodeToString([]byte(testRespInvalidAssertionMissingNotBefore)), + opts: []saml.Option{ + saml.WithClock(clockwork.NewFakeClockAt(fakeTime)), + saml.InsecureSkipSignatureValidation(), + }, + requestID: testRequestId, + wantErrContains: "missing NotBefore attribute on Conditions element", + }, + { + name: "err-assertion-invalid-audience", + sp: testSp, + samlResp: base64.StdEncoding.EncodeToString([]byte(testRespInvalidAssertionAudience)), + opts: []saml.Option{ + saml.WithClock(clockwork.NewFakeClockAt(fakeTime)), + saml.InsecureSkipSignatureValidation(), + }, + requestID: testRequestId, + wantErrContains: "invalid audience", + }, + { + name: "err-no-assertions", + sp: testSp, + samlResp: base64.StdEncoding.EncodeToString([]byte(testRespNoAssertions)), + opts: []saml.Option{ + saml.WithClock(clockwork.NewFakeClockAt(fakeTime)), + saml.InsecureSkipSignatureValidation(), + }, + requestID: testRequestId, + wantErrContains: "unable to validate encoded response: missing Assertion element", + }, + { + name: "err-bad-metatdata-url", + sp: testSpWithInvalidMetadataURL, + samlResp: testExpiredResp, + opts: []saml.Option{ + saml.WithClock(clockwork.NewFakeClockAt(fakeTime)), + }, + requestID: "invalid-request-id", + wantErrContains: "error initializing parser: saml.(ServiceProvider).internalParser: saml.ServiceProvider.FetchIDPMetadata: failed to parse identity provider XML metadata", + }, + { + name: "err-unable-to-parse-resp", + sp: testSp, + samlResp: "unable-to-parse-resp", + opts: []saml.Option{ + saml.WithClock(clockwork.NewFakeClockAt(fakeTime)), + }, + requestID: testRequestId, + wantErrContains: "unable to validate encoded response: illegal base64 data", + }, + { + name: "err-in-response-to", + sp: testSp, + samlResp: testExpiredResp, + opts: []saml.Option{ + saml.WithClock(clockwork.NewFakeClockAt(fakeTime)), + }, + requestID: "invalid-request-id", + wantErrContains: "doesn't match the expected requestID (invalid-request-id)", + }, + { + name: "expired", + sp: testSp, + samlResp: testExpiredResp, + requestID: "request-id", + wantErrAs: &saml2.ErrInvalidValue{}, + wantErrContains: "unable to validate encoded response: Expired NotOnOrAfter value", + }, + { + name: "nil-sp", + wantErrIs: saml.ErrInternal, + wantErrContains: "missing service provider", + }, + { + name: "missing-saml-response", + sp: testSp, + wantErrIs: saml.ErrInvalidParameter, + wantErrContains: "missing saml response", + }, + { + name: "missing-request-id", + sp: testSp, + samlResp: testExpiredResp, + wantErrIs: saml.ErrInvalidParameter, + wantErrContains: "missing request ID", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + got, err := tc.sp.ParseResponse(tc.samlResp, tc.requestID, tc.opts...) + if tc.wantErrContains != "" { + require.Error(err) + assert.Empty(got) + assert.ErrorContains(err, tc.wantErrContains) + if tc.wantErrIs != nil { + assert.ErrorIs(err, tc.wantErrIs) + } + if tc.wantErrAs != nil { + assert.ErrorAs(err, tc.wantErrAs) + } + return + } + require.NoError(err) + assert.Equal(testRequestId, got.InResponseTo) + assert.Equal("http://localhost:8000/saml/acs", got.Destination) + assert.Equal("urn:oasis:names:tc:SAML:2.0:status:Success", got.Status.StatusCode.Value) + assert.Equal(metadataURL, got.Issuer()) + assert.Equal("msmith@samltest.id", got.Assertions()[0].Subject.NameID.Value) + assert.Equal("_35ea90b711d6f385345f0dbdd7d0ed5b", got.Assertions()[0].ID) + }) + } +} + +func TestServiceProvider_ParseResponseCustomACS(t *testing.T) { + t.Parallel() + r := require.New(t) + + fakeTime, err := time.Parse("2006-01-02", "2015-07-15") + r.NoError(err) + + tp := testprovider.StartTestProvider(t) + defer tp.Close() + + cfg, err := saml.NewConfig( + "http://test.me/entity", + "http://test.me/saml/acs", + fmt.Sprintf("%s/saml/metadata", tp.ServerURL()), + ) + r.NoError(err) + + sp, err := saml.NewServiceProvider(cfg) + r.NoError(err) + + encodedResponse := base64.StdEncoding.EncodeToString([]byte(responseUnsigned)) + + type testCase struct { + name string + opts []saml.Option + err string + } + + for _, c := range []testCase{ + { + name: "default url", + opts: []saml.Option{ + saml.WithClock(clockwork.NewFakeClockAt(fakeTime)), + saml.InsecureSkipSignatureValidation(), + }, + }, + { + name: "valid acs url", + opts: []saml.Option{ + saml.WithClock(clockwork.NewFakeClockAt(fakeTime)), + saml.InsecureSkipSignatureValidation(), + saml.WithAssertionConsumerServiceURL("http://test.me/saml/acs"), + }, + }, + { + name: "invalid acs url", + opts: []saml.Option{ + saml.WithClock(clockwork.NewFakeClockAt(fakeTime)), + saml.InsecureSkipSignatureValidation(), + saml.WithAssertionConsumerServiceURL("http://badurl.me"), + }, + err: "Unrecognized Destination value, Expected: http://badurl.me, Actual: http://test.me/saml/acs", + }, + } { + t.Run(c.name, func(t *testing.T) { + _, err = sp.ParseResponse( + encodedResponse, + "ONELOGIN_4fee3b046395c4e751011e97f8900b5273d56685", + c.opts..., + ) + if c.err == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, c.err) + }) + } +} + +// From https://www.samltool.com/generic_sso_res.php +const responseUnsigned = ` + + http://test.idp + + + + + http://test.idp + + _ce3d2948b4cf20146dee0a0b3dd6f69b6cf86f62d7 + + + + + + + http://test.me/entity + + + + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + + + + test + + + test@example.com + + + users + examplerole1 + + + +` + +const testRespNoAssertions = ` + + + https://samltest.id/saml/idp + + + + + + + + + + + + + RV485uKGJZmNA1o56gxxk+VZkvxMqtlHZA2iHH8ZU1Q= + + + d3Lpc6hcSB7bwCzMrO3wfZrNiGk5gZ8rKRKOQENDP2q+p3+LkDmSBt6zzyxn33MCSJt+dPHpF14YMAK/N3PnWwSSUp0j5kzOc9Ka5NdianE0NgYnU0qjhFJbThAQz7hRowS4J49hS/6MuSQ0Z7nBBCeDgeD6PYRApKMvlOtkBGPJaLT2mRy/gnQ+CC6udUdJyvSgb9n43lvxdaaZWrDK3Wga98YlkcRHLrmPAAM8KxYWnkopio6YINU4D5mZjsEsnUkH41WgcwgmS2xzP3ICnNc3WH9NHrVKp9at2DBwrYDIses6FXgYq+iUWK2191jWpIC3qVAB0cOilmRXwtEH7g== + + + MIIDEjCCAfqgAwIBAgIVAMECQ1tjghafm5OxWDh9hwZfxthWMA0GCSqGSIb3DQEBCwUAMBYxFDAS +BgNVBAMMC3NhbWx0ZXN0LmlkMB4XDTE4MDgyNDIxMTQwOVoXDTM4MDgyNDIxMTQwOVowFjEUMBIG +A1UEAwwLc2FtbHRlc3QuaWQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0Z4QX1NFK +s71ufbQwoQoW7qkNAJRIANGA4iM0ThYghul3pC+FwrGv37aTxWXfA1UG9njKbbDreiDAZKngCgyj +xj0uJ4lArgkr4AOEjj5zXA81uGHARfUBctvQcsZpBIxDOvUUImAl+3NqLgMGF2fktxMG7kX3GEVN +c1klbN3dfYsaw5dUrw25DheL9np7G/+28GwHPvLb4aptOiONbCaVvh9UMHEA9F7c0zfF/cL5fOpd +Va54wTI0u12CsFKt78h6lEGG5jUs/qX9clZncJM7EFkN3imPPy+0HC8nspXiH/MZW8o2cqWRkrw3 +MzBZW3Ojk5nQj40V6NUbjb7kfejzAgMBAAGjVzBVMB0GA1UdDgQWBBQT6Y9J3Tw/hOGc8PNV7JEE +4k2ZNTA0BgNVHREELTArggtzYW1sdGVzdC5pZIYcaHR0cHM6Ly9zYW1sdGVzdC5pZC9zYW1sL2lk +cDANBgkqhkiG9w0BAQsFAAOCAQEASk3guKfTkVhEaIVvxEPNR2w3vWt3fwmwJCccW98XXLWgNbu3 +YaMb2RSn7Th4p3h+mfyk2don6au7Uyzc1Jd39RNv80TG5iQoxfCgphy1FYmmdaSfO8wvDtHTTNiL +ArAxOYtzfYbzb5QrNNH/gQEN8RJaEf/g/1GTw9x/103dSMK0RXtl+fRs2nblD1JJKSQ3AdhxK/we +P3aUPtLxVVJ9wMOQOfcy02l+hHMb6uAjsPOpOVKqi3M8XmcUZOpx4swtgGdeoSpeRyrtMvRwdcci +NBp9UZome44qZAYH1iqrpmmjsfI9pJItsgWu3kXPjhSfj1AJGR1l9JGvJrHki1iHTA== + + + + + + + +` + +const testRespInvalidAssertionAudience = ` + + + https://samltest.id/saml/idp + + + + + + + + + + + + + RV485uKGJZmNA1o56gxxk+VZkvxMqtlHZA2iHH8ZU1Q= + + + d3Lpc6hcSB7bwCzMrO3wfZrNiGk5gZ8rKRKOQENDP2q+p3+LkDmSBt6zzyxn33MCSJt+dPHpF14YMAK/N3PnWwSSUp0j5kzOc9Ka5NdianE0NgYnU0qjhFJbThAQz7hRowS4J49hS/6MuSQ0Z7nBBCeDgeD6PYRApKMvlOtkBGPJaLT2mRy/gnQ+CC6udUdJyvSgb9n43lvxdaaZWrDK3Wga98YlkcRHLrmPAAM8KxYWnkopio6YINU4D5mZjsEsnUkH41WgcwgmS2xzP3ICnNc3WH9NHrVKp9at2DBwrYDIses6FXgYq+iUWK2191jWpIC3qVAB0cOilmRXwtEH7g== + + + MIIDEjCCAfqgAwIBAgIVAMECQ1tjghafm5OxWDh9hwZfxthWMA0GCSqGSIb3DQEBCwUAMBYxFDAS +BgNVBAMMC3NhbWx0ZXN0LmlkMB4XDTE4MDgyNDIxMTQwOVoXDTM4MDgyNDIxMTQwOVowFjEUMBIG +A1UEAwwLc2FtbHRlc3QuaWQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0Z4QX1NFK +s71ufbQwoQoW7qkNAJRIANGA4iM0ThYghul3pC+FwrGv37aTxWXfA1UG9njKbbDreiDAZKngCgyj +xj0uJ4lArgkr4AOEjj5zXA81uGHARfUBctvQcsZpBIxDOvUUImAl+3NqLgMGF2fktxMG7kX3GEVN +c1klbN3dfYsaw5dUrw25DheL9np7G/+28GwHPvLb4aptOiONbCaVvh9UMHEA9F7c0zfF/cL5fOpd +Va54wTI0u12CsFKt78h6lEGG5jUs/qX9clZncJM7EFkN3imPPy+0HC8nspXiH/MZW8o2cqWRkrw3 +MzBZW3Ojk5nQj40V6NUbjb7kfejzAgMBAAGjVzBVMB0GA1UdDgQWBBQT6Y9J3Tw/hOGc8PNV7JEE +4k2ZNTA0BgNVHREELTArggtzYW1sdGVzdC5pZIYcaHR0cHM6Ly9zYW1sdGVzdC5pZC9zYW1sL2lk +cDANBgkqhkiG9w0BAQsFAAOCAQEASk3guKfTkVhEaIVvxEPNR2w3vWt3fwmwJCccW98XXLWgNbu3 +YaMb2RSn7Th4p3h+mfyk2don6au7Uyzc1Jd39RNv80TG5iQoxfCgphy1FYmmdaSfO8wvDtHTTNiL +ArAxOYtzfYbzb5QrNNH/gQEN8RJaEf/g/1GTw9x/103dSMK0RXtl+fRs2nblD1JJKSQ3AdhxK/we +P3aUPtLxVVJ9wMOQOfcy02l+hHMb6uAjsPOpOVKqi3M8XmcUZOpx4swtgGdeoSpeRyrtMvRwdcci +NBp9UZome44qZAYH1iqrpmmjsfI9pJItsgWu3kXPjhSfj1AJGR1l9JGvJrHki1iHTA== + + + + + + + + https://samltest.id/saml/idp + + msmith@samltest.id + + + + + + + + . + http://saml.julz/invalid-audience + + + + + + urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport + + + + + Ambassador + None + + + msmith@samltest.id + + + + morty + + + +1-555-555-5505 + + + janitor@samltest.id + + + + msmith@samltest.id + + + Smith + + + Morty Smith + + + Mortimer + + + + ` + +const testRespInvalidAssertionMissingNotBefore = ` + + + https://samltest.id/saml/idp + + + + + + + + + + + + + RV485uKGJZmNA1o56gxxk+VZkvxMqtlHZA2iHH8ZU1Q= + + + d3Lpc6hcSB7bwCzMrO3wfZrNiGk5gZ8rKRKOQENDP2q+p3+LkDmSBt6zzyxn33MCSJt+dPHpF14YMAK/N3PnWwSSUp0j5kzOc9Ka5NdianE0NgYnU0qjhFJbThAQz7hRowS4J49hS/6MuSQ0Z7nBBCeDgeD6PYRApKMvlOtkBGPJaLT2mRy/gnQ+CC6udUdJyvSgb9n43lvxdaaZWrDK3Wga98YlkcRHLrmPAAM8KxYWnkopio6YINU4D5mZjsEsnUkH41WgcwgmS2xzP3ICnNc3WH9NHrVKp9at2DBwrYDIses6FXgYq+iUWK2191jWpIC3qVAB0cOilmRXwtEH7g== + + + MIIDEjCCAfqgAwIBAgIVAMECQ1tjghafm5OxWDh9hwZfxthWMA0GCSqGSIb3DQEBCwUAMBYxFDAS +BgNVBAMMC3NhbWx0ZXN0LmlkMB4XDTE4MDgyNDIxMTQwOVoXDTM4MDgyNDIxMTQwOVowFjEUMBIG +A1UEAwwLc2FtbHRlc3QuaWQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0Z4QX1NFK +s71ufbQwoQoW7qkNAJRIANGA4iM0ThYghul3pC+FwrGv37aTxWXfA1UG9njKbbDreiDAZKngCgyj +xj0uJ4lArgkr4AOEjj5zXA81uGHARfUBctvQcsZpBIxDOvUUImAl+3NqLgMGF2fktxMG7kX3GEVN +c1klbN3dfYsaw5dUrw25DheL9np7G/+28GwHPvLb4aptOiONbCaVvh9UMHEA9F7c0zfF/cL5fOpd +Va54wTI0u12CsFKt78h6lEGG5jUs/qX9clZncJM7EFkN3imPPy+0HC8nspXiH/MZW8o2cqWRkrw3 +MzBZW3Ojk5nQj40V6NUbjb7kfejzAgMBAAGjVzBVMB0GA1UdDgQWBBQT6Y9J3Tw/hOGc8PNV7JEE +4k2ZNTA0BgNVHREELTArggtzYW1sdGVzdC5pZIYcaHR0cHM6Ly9zYW1sdGVzdC5pZC9zYW1sL2lk +cDANBgkqhkiG9w0BAQsFAAOCAQEASk3guKfTkVhEaIVvxEPNR2w3vWt3fwmwJCccW98XXLWgNbu3 +YaMb2RSn7Th4p3h+mfyk2don6au7Uyzc1Jd39RNv80TG5iQoxfCgphy1FYmmdaSfO8wvDtHTTNiL +ArAxOYtzfYbzb5QrNNH/gQEN8RJaEf/g/1GTw9x/103dSMK0RXtl+fRs2nblD1JJKSQ3AdhxK/we +P3aUPtLxVVJ9wMOQOfcy02l+hHMb6uAjsPOpOVKqi3M8XmcUZOpx4swtgGdeoSpeRyrtMvRwdcci +NBp9UZome44qZAYH1iqrpmmjsfI9pJItsgWu3kXPjhSfj1AJGR1l9JGvJrHki1iHTA== + + + + + + + + https://samltest.id/saml/idp + + msmith@samltest.id + + + + + + + + + http://saml.julz/example + + + + + + urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport + + + + + Ambassador + None + + + msmith@samltest.id + + + + morty + + + +1-555-555-5505 + + + janitor@samltest.id + + + + msmith@samltest.id + + + Smith + + + Morty Smith + + + Mortimer + + + + +` + +const testRespInvalidAssertionMissingSubject = ` + + + https://samltest.id/saml/idp + + + + + + + + + + + + + RV485uKGJZmNA1o56gxxk+VZkvxMqtlHZA2iHH8ZU1Q= + + + d3Lpc6hcSB7bwCzMrO3wfZrNiGk5gZ8rKRKOQENDP2q+p3+LkDmSBt6zzyxn33MCSJt+dPHpF14YMAK/N3PnWwSSUp0j5kzOc9Ka5NdianE0NgYnU0qjhFJbThAQz7hRowS4J49hS/6MuSQ0Z7nBBCeDgeD6PYRApKMvlOtkBGPJaLT2mRy/gnQ+CC6udUdJyvSgb9n43lvxdaaZWrDK3Wga98YlkcRHLrmPAAM8KxYWnkopio6YINU4D5mZjsEsnUkH41WgcwgmS2xzP3ICnNc3WH9NHrVKp9at2DBwrYDIses6FXgYq+iUWK2191jWpIC3qVAB0cOilmRXwtEH7g== + + + MIIDEjCCAfqgAwIBAgIVAMECQ1tjghafm5OxWDh9hwZfxthWMA0GCSqGSIb3DQEBCwUAMBYxFDAS +BgNVBAMMC3NhbWx0ZXN0LmlkMB4XDTE4MDgyNDIxMTQwOVoXDTM4MDgyNDIxMTQwOVowFjEUMBIG +A1UEAwwLc2FtbHRlc3QuaWQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0Z4QX1NFK +s71ufbQwoQoW7qkNAJRIANGA4iM0ThYghul3pC+FwrGv37aTxWXfA1UG9njKbbDreiDAZKngCgyj +xj0uJ4lArgkr4AOEjj5zXA81uGHARfUBctvQcsZpBIxDOvUUImAl+3NqLgMGF2fktxMG7kX3GEVN +c1klbN3dfYsaw5dUrw25DheL9np7G/+28GwHPvLb4aptOiONbCaVvh9UMHEA9F7c0zfF/cL5fOpd +Va54wTI0u12CsFKt78h6lEGG5jUs/qX9clZncJM7EFkN3imPPy+0HC8nspXiH/MZW8o2cqWRkrw3 +MzBZW3Ojk5nQj40V6NUbjb7kfejzAgMBAAGjVzBVMB0GA1UdDgQWBBQT6Y9J3Tw/hOGc8PNV7JEE +4k2ZNTA0BgNVHREELTArggtzYW1sdGVzdC5pZIYcaHR0cHM6Ly9zYW1sdGVzdC5pZC9zYW1sL2lk +cDANBgkqhkiG9w0BAQsFAAOCAQEASk3guKfTkVhEaIVvxEPNR2w3vWt3fwmwJCccW98XXLWgNbu3 +YaMb2RSn7Th4p3h+mfyk2don6au7Uyzc1Jd39RNv80TG5iQoxfCgphy1FYmmdaSfO8wvDtHTTNiL +ArAxOYtzfYbzb5QrNNH/gQEN8RJaEf/g/1GTw9x/103dSMK0RXtl+fRs2nblD1JJKSQ3AdhxK/we +P3aUPtLxVVJ9wMOQOfcy02l+hHMb6uAjsPOpOVKqi3M8XmcUZOpx4swtgGdeoSpeRyrtMvRwdcci +NBp9UZome44qZAYH1iqrpmmjsfI9pJItsgWu3kXPjhSfj1AJGR1l9JGvJrHki1iHTA== + + + + + + + + https://samltest.id/saml/idp + + + + http://saml.julz/example + + + + + + urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport + + + + + Ambassador + None + + + msmith@samltest.id + + + + morty + + + +1-555-555-5505 + + + janitor@samltest.id + + + + msmith@samltest.id + + + Smith + + + Morty Smith + + + Mortimer + + + + +` + +const testRespInvalidAssertionMissingAttributeStmt = ` + + + https://samltest.id/saml/idp + + + + + + + + + + + + + RV485uKGJZmNA1o56gxxk+VZkvxMqtlHZA2iHH8ZU1Q= + + + d3Lpc6hcSB7bwCzMrO3wfZrNiGk5gZ8rKRKOQENDP2q+p3+LkDmSBt6zzyxn33MCSJt+dPHpF14YMAK/N3PnWwSSUp0j5kzOc9Ka5NdianE0NgYnU0qjhFJbThAQz7hRowS4J49hS/6MuSQ0Z7nBBCeDgeD6PYRApKMvlOtkBGPJaLT2mRy/gnQ+CC6udUdJyvSgb9n43lvxdaaZWrDK3Wga98YlkcRHLrmPAAM8KxYWnkopio6YINU4D5mZjsEsnUkH41WgcwgmS2xzP3ICnNc3WH9NHrVKp9at2DBwrYDIses6FXgYq+iUWK2191jWpIC3qVAB0cOilmRXwtEH7g== + + + MIIDEjCCAfqgAwIBAgIVAMECQ1tjghafm5OxWDh9hwZfxthWMA0GCSqGSIb3DQEBCwUAMBYxFDAS +BgNVBAMMC3NhbWx0ZXN0LmlkMB4XDTE4MDgyNDIxMTQwOVoXDTM4MDgyNDIxMTQwOVowFjEUMBIG +A1UEAwwLc2FtbHRlc3QuaWQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0Z4QX1NFK +s71ufbQwoQoW7qkNAJRIANGA4iM0ThYghul3pC+FwrGv37aTxWXfA1UG9njKbbDreiDAZKngCgyj +xj0uJ4lArgkr4AOEjj5zXA81uGHARfUBctvQcsZpBIxDOvUUImAl+3NqLgMGF2fktxMG7kX3GEVN +c1klbN3dfYsaw5dUrw25DheL9np7G/+28GwHPvLb4aptOiONbCaVvh9UMHEA9F7c0zfF/cL5fOpd +Va54wTI0u12CsFKt78h6lEGG5jUs/qX9clZncJM7EFkN3imPPy+0HC8nspXiH/MZW8o2cqWRkrw3 +MzBZW3Ojk5nQj40V6NUbjb7kfejzAgMBAAGjVzBVMB0GA1UdDgQWBBQT6Y9J3Tw/hOGc8PNV7JEE +4k2ZNTA0BgNVHREELTArggtzYW1sdGVzdC5pZIYcaHR0cHM6Ly9zYW1sdGVzdC5pZC9zYW1sL2lk +cDANBgkqhkiG9w0BAQsFAAOCAQEASk3guKfTkVhEaIVvxEPNR2w3vWt3fwmwJCccW98XXLWgNbu3 +YaMb2RSn7Th4p3h+mfyk2don6au7Uyzc1Jd39RNv80TG5iQoxfCgphy1FYmmdaSfO8wvDtHTTNiL +ArAxOYtzfYbzb5QrNNH/gQEN8RJaEf/g/1GTw9x/103dSMK0RXtl+fRs2nblD1JJKSQ3AdhxK/we +P3aUPtLxVVJ9wMOQOfcy02l+hHMb6uAjsPOpOVKqi3M8XmcUZOpx4swtgGdeoSpeRyrtMvRwdcci +NBp9UZome44qZAYH1iqrpmmjsfI9pJItsgWu3kXPjhSfj1AJGR1l9JGvJrHki1iHTA== + + + + + + + + https://samltest.id/saml/idp + + msmith@samltest.id + + + + + + + + . + http://saml.julz/example + + + + + + urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport + + + + + ` diff --git a/saml/sp.go b/saml/sp.go new file mode 100644 index 00000000..b4c828d4 --- /dev/null +++ b/saml/sp.go @@ -0,0 +1,391 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package saml + +import ( + _ "embed" + "encoding/base64" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "sync" + "time" + + "github.com/hashicorp/cap/saml/models/core" + "github.com/hashicorp/cap/saml/models/metadata" + "github.com/jonboulle/clockwork" + dsig "github.com/russellhaering/goxmldsig/types" +) + +//go:embed authn_request.gohtml +var postBindingTempl string + +type metadataOptions struct { + wantAssertionsSigned bool + nameIDFormats []core.NameIDFormat + acsServiceBinding core.ServiceBinding + additionalACSs []metadata.Endpoint +} + +func metadataOptionsDefault() metadataOptions { + return metadataOptions{ + wantAssertionsSigned: true, + acsServiceBinding: core.ServiceBindingHTTPPost, + } +} + +func getMetadataOptions(opt ...Option) metadataOptions { + opts := metadataOptionsDefault() + ApplyOpts(&opts, opt...) + return opts +} + +// InsecureWantAssertionsUnsigned provides a way to optionally request that you +// want insecure/unsigned assertions. +func InsecureWantAssertionsUnsigned() Option { + return func(o interface{}) { + if o, ok := o.(*metadataOptions); ok { + o.wantAssertionsSigned = false + } + } +} + +// WithMetadataNameIDFormat provides an optional name ID formats, which are +// added to the existing set. +func WithMetadataNameIDFormat(format ...core.NameIDFormat) Option { + return func(o interface{}) { + if o, ok := o.(*metadataOptions); ok { + o.nameIDFormats = append(o.nameIDFormats, format...) + } + } +} + +// WithACSServiceBinding provides an optional service binding. +func WithACSServiceBinding(b core.ServiceBinding) Option { + return func(o interface{}) { + if o, ok := o.(*metadataOptions); ok { + o.acsServiceBinding = b + } + } +} + +// WithAdditionalACSEndpoint provides an optional additional ACS endpoint +func WithAdditionalACSEndpoint(b core.ServiceBinding, location url.URL) Option { + return func(o interface{}) { + if o, ok := o.(*metadataOptions); ok { + o.additionalACSs = append(o.additionalACSs, metadata.Endpoint{ + Binding: b, + Location: location.String(), + }) + } + } +} + +// ServiceProvider defines a type for service providers +type ServiceProvider struct { + cfg *Config + + metadata *metadata.EntityDescriptorIDPSSO + metadataCachedUntil *time.Time + metadataLock sync.Mutex +} + +// NewServiceProvider creates a new ServiceProvider. +func NewServiceProvider(cfg *Config) (*ServiceProvider, error) { + const op = "saml.NewServiceProvider" + + if cfg == nil { + return nil, fmt.Errorf( + "%s: no provider config provided", + op, + ) + } + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf( + "%s: insufficient provider config: %w", + op, err, + ) + } + + return &ServiceProvider{ + cfg: cfg, + }, nil +} + +// Config returns the service provider config. +func (sp *ServiceProvider) Config() *Config { + return sp.cfg +} + +// CreateMetadata creates the metadata XML for the service provider. +// +// Options: +// - InsecureWantAssertionsUnsigned +// - WithNameIDFormats +// - WithACSServiceBinding +// - WithAdditonalACSEndpoint +func (sp *ServiceProvider) CreateMetadata(opt ...Option) *metadata.EntityDescriptorSPSSO { + validUntil := sp.cfg.ValidUntil() + + opts := getMetadataOptions(opt...) + + spsso := metadata.EntityDescriptorSPSSO{} + spsso.EntityID = sp.cfg.EntityID + spsso.ValidUntil = &validUntil + + spssoDescriptor := &metadata.SPSSODescriptor{} + spssoDescriptor.ProtocolSupportEnumeration = metadata.ProtocolSupportEnumerationProtocol + spssoDescriptor.NameIDFormat = opts.nameIDFormats + spssoDescriptor.AuthnRequestsSigned = false // always false for now until request signing is supported. + spssoDescriptor.WantAssertionsSigned = opts.wantAssertionsSigned + spssoDescriptor.AssertionConsumerService = []metadata.IndexedEndpoint{ + { + Endpoint: metadata.Endpoint{ + Binding: opts.acsServiceBinding, + Location: sp.cfg.AssertionConsumerServiceURL, + }, + Index: 1, + }, + } + + for i, a := range opts.additionalACSs { + spssoDescriptor.AssertionConsumerService = append( + spssoDescriptor.AssertionConsumerService, + metadata.IndexedEndpoint{ + Endpoint: a, + Index: i + 2, // The first index is already taken. + }, + ) + } + + spsso.SPSSODescriptor = []*metadata.SPSSODescriptor{spssoDescriptor} + + return &spsso +} + +type idpMetadataOptions struct { + cache bool + useStale bool + clock clockwork.Clock +} + +func idpMetadataOptionsDefault() idpMetadataOptions { + return idpMetadataOptions{ + cache: true, + useStale: false, + clock: clockwork.NewRealClock(), + } +} + +func getIDPMetadataOptions(opt ...Option) idpMetadataOptions { + opts := idpMetadataOptionsDefault() + ApplyOpts(&opts, opt...) + return opts +} + +// WithCache control whether we should cache IDP Metadata. +func WithCache(cache bool) Option { + return func(o interface{}) { + if o, ok := o.(*idpMetadataOptions); ok { + o.cache = cache + } + } +} + +// WithStale control whether we should use a stale IDP Metadata document if +// refreshing it fails. +func WithStale(stale bool) Option { + return func(o interface{}) { + if o, ok := o.(*idpMetadataOptions); ok { + o.useStale = stale + } + } +} + +// IDPMetadata fetches the metadata XML document from the configured identity provider. +// Options: +// - WithClock +// - WithCache +// - WithStale +func (sp *ServiceProvider) IDPMetadata(opt ...Option) (*metadata.EntityDescriptorIDPSSO, error) { + const op = "saml.ServiceProvider.FetchIDPMetadata" + + opts := getIDPMetadataOptions(opt...) + + var err error + var ed *metadata.EntityDescriptorIDPSSO + + isValid := func(md *metadata.EntityDescriptorIDPSSO) bool { + if md == nil { + return false + } + if md.ValidUntil == nil { + return true + } + return opts.clock.Now().Before(*md.ValidUntil) + } + + isAlive := func(md *metadata.EntityDescriptorIDPSSO, expireAt *time.Time) bool { + if md == nil || !opts.cache || expireAt == nil { + return false + } + + return opts.clock.Now().Before(*expireAt) + } + + if opts.cache { + // We only take the lock when caching is enabled so that requests can be + // done concurrently when it is not + sp.metadataLock.Lock() + defer sp.metadataLock.Unlock() + + switch { + case !isValid(sp.metadata): + sp.metadata = nil + sp.metadataCachedUntil = nil + case isValid(sp.metadata) && isAlive(sp.metadata, sp.metadataCachedUntil): + return sp.metadata, nil + } + } + + // Order of switch case determines IDP metadata config precedence + switch { + case sp.cfg.MetadataURL != "": + ed, err = fetchIDPMetadata(sp.cfg.MetadataURL) + switch { + case err != nil && opts.useStale && isValid(sp.metadata): + // An error occurred but we have a cached metadata document that + // we can use + return sp.metadata, nil + case err != nil: + return nil, fmt.Errorf("%s: %w", op, err) + } + + case sp.cfg.MetadataXML != "": + ed, err = parseIDPMetadata([]byte(sp.cfg.MetadataXML)) + if err != nil { + return nil, fmt.Errorf("%s: %w", op, err) + } + + case sp.cfg.MetadataParameters != nil: + ed, err = constructIDPMetadata(sp.cfg.MetadataParameters) + if err != nil { + return nil, fmt.Errorf("%s: %w", op, err) + } + + default: + return nil, fmt.Errorf("%s: no IDP metadata configuration set: %w", op, ErrInvalidParameter) + } + + if !isValid(ed) { + return nil, fmt.Errorf("the IDP configuration was only valid until %s", ed.ValidUntil.Format(time.RFC3339)) + } + + sp.metadata = ed + sp.metadataCachedUntil = nil + if sp.metadata.CacheDuration != nil { + cachedUntil := opts.clock.Now().Add(time.Duration(*sp.metadata.CacheDuration)) + sp.metadataCachedUntil = &cachedUntil + } + + return ed, err +} + +func (sp *ServiceProvider) destination(binding core.ServiceBinding) (string, error) { + const op = "saml.ServiceProvider.destination" + + meta, err := sp.IDPMetadata() + if err != nil { + return "", fmt.Errorf("%s: failed to fetch metadata: %w", op, err) + } + + destination, ok := meta.GetLocationForBinding(binding) + if !ok { + return "", fmt.Errorf( + "%s: no location for provided binding (%s) found: %w", + op, binding, ErrBindingUnsupported, + ) + } + + return destination, nil +} + +func fetchIDPMetadata(metadataURL string) (*metadata.EntityDescriptorIDPSSO, error) { + res, err := http.Get(metadataURL) + if err != nil { + return nil, fmt.Errorf("failed to fetch identity provider metadata: %w", err) + } + + raw, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("failed to read http body: %w", err) + } + + meta, err := parseIDPMetadata(raw) + if err != nil { + return nil, err + } + + return meta, err +} + +func parseIDPMetadata(rawXML []byte) (*metadata.EntityDescriptorIDPSSO, error) { + var ed metadata.EntityDescriptorIDPSSO + if err := xml.Unmarshal(rawXML, &ed); err != nil { + return nil, fmt.Errorf("failed to parse identity provider XML metadata: %w", err) + } + + // [SDP-MD03] https://kantarainitiative.github.io/SAMLprofiles/saml2int.html#_metadata_and_trust_management + // IDPMetadata without a validUntil attribute on its root element MUST be rejected. IDPMetadata whose root element’s validUntil + // attribute extends beyond a deployer- or community-imposed threshold MUST be rejected. + // TODO: VALIDATE + + return &ed, nil +} + +func constructIDPMetadata(params *MetadataParameters) (*metadata.EntityDescriptorIDPSSO, error) { + cert, err := parsePEMCertificate([]byte(params.IDPCertificate)) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate: %w", err) + } + + keyDescriptor := metadata.KeyDescriptor{ + Use: metadata.KeyTypeSigning, + KeyInfo: metadata.KeyInfo{ + KeyInfo: dsig.KeyInfo{ + X509Data: dsig.X509Data{ + X509Certificates: []dsig.X509Certificate{ + { + Data: base64.StdEncoding.EncodeToString(cert.Raw), + }, + }, + }, + }, + }, + } + + idpSSODescriptor := &metadata.IDPSSODescriptor{ + SSODescriptor: metadata.SSODescriptor{ + RoleDescriptor: metadata.RoleDescriptor{ + KeyDescriptor: []metadata.KeyDescriptor{keyDescriptor}, + }, + }, + WantAuthnRequestsSigned: false, + SingleSignOnService: []metadata.Endpoint{ + { + Binding: params.Binding, + Location: params.SingleSignOnURL, + }, + }, + } + + return &metadata.EntityDescriptorIDPSSO{ + EntityDescriptor: metadata.EntityDescriptor{ + EntityID: params.Issuer, + }, + IDPSSODescriptor: []*metadata.IDPSSODescriptor{idpSSODescriptor}, + }, nil +} diff --git a/saml/sp_test.go b/saml/sp_test.go new file mode 100644 index 00000000..77aabb4b --- /dev/null +++ b/saml/sp_test.go @@ -0,0 +1,430 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package saml_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/require" + + "github.com/hashicorp/cap/saml" + "github.com/hashicorp/cap/saml/models/core" + "github.com/hashicorp/cap/saml/models/metadata" +) + +func Test_NewServiceProvider(t *testing.T) { + t.Parallel() + r := require.New(t) + exampleURL := "http://test.me" + + validConfig, err := saml.NewConfig( + exampleURL, + exampleURL, + exampleURL, + ) + r.NoError(err) + + cases := []struct { + name string + cfg *saml.Config + err string + }{ + { + name: "When a valid config is provided", + cfg: validConfig, + err: "", + }, + { + name: "When an invalid config is provided", + cfg: &saml.Config{}, + err: "saml.NewServiceProvider: insufficient provider config:", + }, + { + name: "When no config is provided", + cfg: nil, + err: "saml.NewServiceProvider: no provider config provided", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := require.New(t) + got, err := saml.NewServiceProvider(c.cfg) + + if c.err != "" { + r.Error(err) + r.ErrorContains(err, c.err) + return + } + r.NoError(err) + r.NotNil(got) + r.NotNil(got.Config()) + }) + } +} + +func Test_ServiceProvider_FetchMetadata_ErrorCases(t *testing.T) { + t.Parallel() + r := require.New(t) + + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write([]byte("")) + })) + defer s.Close() + + fakeURL := "http://cap.saml.fake" + metaURL := fmt.Sprintf("%s/saml/metadata", s.URL) + + cfg, err := saml.NewConfig( + fakeURL, + fakeURL, + fakeURL, + ) + r.NoError(err) + + cases := []struct { + name string + metadata string + wantErr string + }{ + { + name: "When the metadata can't be fetched", + metadata: fakeURL, + wantErr: "saml.ServiceProvider.FetchIDPMetadata: failed to fetch identity provider metadata:", + }, + { + name: "When the metadata XML can't be parsed", + metadata: metaURL, + wantErr: "saml.ServiceProvider.FetchIDPMetadata: failed to parse identity provider XML metadata:", + }, + } + + for _, c := range cases { + cfg.MetadataURL = c.metadata + + provider, err := saml.NewServiceProvider(cfg) + r.NoError(err) + + t.Run(c.name, func(t *testing.T) { + r := require.New(t) + got, err := provider.IDPMetadata() + r.Nil(got) + r.Error(err) + r.ErrorContains(err, c.wantErr) + }) + } +} + +func Test_ServiceProvider_FetchMetadata_Cache(t *testing.T) { + type testServer struct { + fail bool + failOnRefresh bool + } + + newTestServer := func(t *testing.T, failOnRefresh bool) string { + t.Helper() + + ts := &testServer{false, failOnRefresh} + + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if !ts.fail { + w.Write([]byte(exampleIDPSSODescriptorX)) + } + ts.fail = ts.fail || ts.failOnRefresh + })) + t.Cleanup(s.Close) + + return s.URL + } + + cases := []struct { + name string + newTime string + shouldBeCached bool + opts []saml.Option + failOnRefresh bool + expectErrorOnRefresh bool + }{ + { + name: "is cached", + shouldBeCached: true, + }, + { + name: "cache is disabled", + opts: []saml.Option{saml.WithCache(false)}, + shouldBeCached: false, + }, + { + name: "stale cached document should not be used", + newTime: "2017-07-26", + shouldBeCached: false, + }, + { + name: "is not cached once validUntil is reached", + newTime: "2018-07-25", + expectErrorOnRefresh: true, + }, + { + name: "a stale document should not be used if refreshing fails", + newTime: "2017-07-26", + failOnRefresh: true, + expectErrorOnRefresh: true, + }, + { + name: "use stale document", + opts: []saml.Option{saml.WithStale(true)}, + newTime: "2017-07-26", + failOnRefresh: true, + shouldBeCached: true, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + r := require.New(t) + + url := newTestServer(t, tt.failOnRefresh) + metaURL := fmt.Sprintf("%s/saml/metadata", url) + cfg, err := saml.NewConfig( + metaURL, + metaURL, + metaURL, + ) + r.NoError(err) + + provider, err := saml.NewServiceProvider(cfg) + r.NoError(err) + + newTime, err := time.Parse("2006-01-02", "2017-07-25") + r.NoError(err) + + opts := append([]saml.Option{saml.WithClock(clockwork.NewFakeClockAt(newTime))}, tt.opts...) + + got1, err := provider.IDPMetadata(opts...) + r.NoError(err) + r.NotNil(got1) + + if tt.newTime != "" { + newTime, err = time.Parse("2006-01-02", tt.newTime) + r.NoError(err) + opts = append(opts, saml.WithClock(clockwork.NewFakeClockAt(newTime))) + } + + got2, err := provider.IDPMetadata(opts...) + if tt.expectErrorOnRefresh { + r.Error(err) + return + } + r.NoError(err) + r.NotNil(got2) + + if tt.shouldBeCached { + r.True(got1 == got2) + } else { + r.False(got1 == got2) + } + }) + } +} + +func Test_ServiceProvider_CreateMetadata(t *testing.T) { + t.Parallel() + r := require.New(t) + + entityID := "http://test.me/entity" + acs := "http://test.me/saml/acs" + meta := "http://test.me/sso/metadata" + + now := time.Now() + validUntil := func() time.Time { + return now + } + + cfg, err := saml.NewConfig( + entityID, + acs, + meta, + ) + r.NoError(err) + + cfg.ValidUntil = validUntil + + provider, err := saml.NewServiceProvider(cfg) + r.NoError(err) + + cases := []struct { + name string + nameIDFormats []core.NameIDFormat + }{ + { + name: "", + }, + { + name: "email", + nameIDFormats: []core.NameIDFormat{core.NameIDFormatEmail}, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := require.New(t) + opts := []saml.Option{} + if c.nameIDFormats != nil { + opts = append(opts, saml.WithMetadataNameIDFormat(c.nameIDFormats...)) + } + got := provider.CreateMetadata(opts...) + + r.Equal(&now, got.ValidUntil) + r.Equal("http://test.me/entity", got.EntityID) + + r.Len(got.SPSSODescriptor, 1) + r.True(got.SPSSODescriptor[0].WantAssertionsSigned) + r.False(got.SPSSODescriptor[0].AuthnRequestsSigned) + r.Equal( + metadata.ProtocolSupportEnumerationProtocol, + got.SPSSODescriptor[0].ProtocolSupportEnumeration, + ) + r.Equal( + core.ServiceBindingHTTPPost, + got.SPSSODescriptor[0].AssertionConsumerService[0].Binding, + ) + r.Equal(1, got.SPSSODescriptor[0].AssertionConsumerService[0].Index) + r.Equal( + "http://test.me/saml/acs", + got.SPSSODescriptor[0].AssertionConsumerService[0].Location, + ) + r.Equal(got.SPSSODescriptor[0].NameIDFormat, c.nameIDFormats) + }) + } +} + +func Test_CreateMetadata_Options(t *testing.T) { + t.Parallel() + r := require.New(t) + + fakeURL := "http://fake.test.url" + + cfg, err := saml.NewConfig( + fakeURL, + fakeURL, + fakeURL, + ) + + provider, err := saml.NewServiceProvider(cfg) + r.NoError(err) + + t.Run("When option InsecureWantAssertionsUnsigned is set", func(t *testing.T) { + r := require.New(t) + got := provider.CreateMetadata( + saml.InsecureWantAssertionsUnsigned(), + ) + + r.False(got.SPSSODescriptor[0].WantAssertionsSigned) + }) + + t.Run("When option WithAdditionalNameIDFormat is set", func(t *testing.T) { + r := require.New(t) + got := provider.CreateMetadata( + saml.WithMetadataNameIDFormat(core.NameIDFormatTransient), + ) + + r.Equal(got.SPSSODescriptor[0].NameIDFormat, []core.NameIDFormat{core.NameIDFormatTransient}) + }) + + t.Run("When option WithNameIDFormats is set", func(t *testing.T) { + r := require.New(t) + got := provider.CreateMetadata( + saml.WithMetadataNameIDFormat(core.NameIDFormatEntity, core.NameIDFormatUnspecified), + ) + + r.Len(got.SPSSODescriptor[0].NameIDFormat, 2) + r.Equal(got.SPSSODescriptor[0].NameIDFormat, []core.NameIDFormat{ + core.NameIDFormatEntity, + core.NameIDFormatUnspecified, + }) + }) + + t.Run("When option WithACSServiceBinding is set", func(t *testing.T) { + r := require.New(t) + got := provider.CreateMetadata( + saml.WithACSServiceBinding(core.ServiceBindingHTTPRedirect), + ) + + r.Len(got.SPSSODescriptor[0].AssertionConsumerService, 1) + r.Equal( + got.SPSSODescriptor[0].AssertionConsumerService[0].Binding, + core.ServiceBindingHTTPRedirect, + ) + }) + + t.Run("When option WithAdditionalACSEndpoint is set", func(t *testing.T) { + r := require.New(t) + redirectEndpoint, err := url.Parse("http://cap.saml.test/acs/redirect") + r.NoError(err) + + got := provider.CreateMetadata( + saml.WithAdditionalACSEndpoint( + core.ServiceBindingHTTPRedirect, + *redirectEndpoint, + ), + ) + + r.Len(got.SPSSODescriptor[0].AssertionConsumerService, 2) + r.Equal( + got.SPSSODescriptor[0].AssertionConsumerService[0], + metadata.IndexedEndpoint{ + Endpoint: metadata.Endpoint{ + Binding: core.ServiceBindingHTTPPost, + Location: fakeURL, + }, + Index: 1, + }, + ) + + r.Equal( + got.SPSSODescriptor[0].AssertionConsumerService[1], + metadata.IndexedEndpoint{ + Endpoint: metadata.Endpoint{ + Binding: core.ServiceBindingHTTPRedirect, + Location: redirectEndpoint.String(), + }, + Index: 2, + }, + ) + }) +} + +var exampleIDPSSODescriptorX = ` + + + + + + + https://registrar.example.net/category/self-certified + + + + + + ... + ... + https://www.example.info/ + + + SAML Technical Support + mailto:technical-support@example.info + + +` diff --git a/saml/test/provider.go b/saml/test/provider.go new file mode 100644 index 00000000..b4b9d7f3 --- /dev/null +++ b/saml/test/provider.go @@ -0,0 +1,419 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package testprovider + +import ( + "bytes" + "compress/flate" + "encoding/base64" + "encoding/json" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/cap/saml/models/core" + "github.com/hashicorp/cap/saml/models/metadata" +) + +// ID must start with a letter or underscore. +var idRegexp = regexp.MustCompile(`\A[a-zA-Z_]`) + +const meta = ` + + + + + + MIICajCCAdOgAwIBAgIBADANBgkqhkiG9w0BAQ0FADBSMQswCQYDVQQGEwJ1czETMBEGA1UECAwKQ2FsaWZvcm5pYTEVMBMGA1UECgwMT25lbG9naW4gSW5jMRcwFQYDVQQDDA5zcC5leGFtcGxlLmNvbTAeFw0xNDA3MTcxNDEyNTZaFw0xNTA3MTcxNDEyNTZaMFIxCzAJBgNVBAYTAnVzMRMwEQYDVQQIDApDYWxpZm9ybmlhMRUwEwYDVQQKDAxPbmVsb2dpbiBJbmMxFzAVBgNVBAMMDnNwLmV4YW1wbGUuY29tMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDZx+ON4IUoIWxgukTb1tOiX3bMYzYQiwWPUNMp+Fq82xoNogso2bykZG0yiJm5o8zv/sd6pGouayMgkx/2FSOdc36T0jGbCHuRSbtia0PEzNIRtmViMrt3AeoWBidRXmZsxCNLwgIV6dn2WpuE5Az0bHgpZnQxTKFek0BMKU/d8wIDAQABo1AwTjAdBgNVHQ4EFgQUGHxYqZYyX7cTxKVODVgZwSTdCnwwHwYDVR0jBBgwFoAUGHxYqZYyX7cTxKVODVgZwSTdCnwwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQ0FAAOBgQByFOl+hMFICbd3DJfnp2Rgd/dqttsZG/tyhILWvErbio/DEe98mXpowhTkC04ENprOyXi7ZbUqiicF89uAGyt1oqgTUCD1VsLahqIcmrzgumNyTwLGWo17WDAa1/usDhetWAMhgzF/Cnf5ek0nK00m0YZGyc4LzgD0CROMASTWNg== + + + + urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + + + + +` + +// From https://www.samltool.com/generic_sso_res.php +const responseSigned = ` + + http://idp.example.com/metadata.php + + + + + http://idp.example.com/metadata.php + + + CtIHbEceX42xKr7zJ/642uXWROg=ALoS5nPK3X14WITy+5W/GYbdfpBBfqYugw3R69+QQa0pu7hy0VG2nr5LzEe4n1YbLd0rA2q5N6jtCuicv9Mfvk9SatkNhuP1TDnIeX4muOx/tu7hkCyaR9IeLfIVa9kohi1uGLqffGTBNUlIO0PpCPxwlmKCiio4zOUa/Dln8vs= +MIICajCCAdOgAwIBAgIBADANBgkqhkiG9w0BAQ0FADBSMQswCQYDVQQGEwJ1czETMBEGA1UECAwKQ2FsaWZvcm5pYTEVMBMGA1UECgwMT25lbG9naW4gSW5jMRcwFQYDVQQDDA5zcC5leGFtcGxlLmNvbTAeFw0xNDA3MTcxNDEyNTZaFw0xNTA3MTcxNDEyNTZaMFIxCzAJBgNVBAYTAnVzMRMwEQYDVQQIDApDYWxpZm9ybmlhMRUwEwYDVQQKDAxPbmVsb2dpbiBJbmMxFzAVBgNVBAMMDnNwLmV4YW1wbGUuY29tMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDZx+ON4IUoIWxgukTb1tOiX3bMYzYQiwWPUNMp+Fq82xoNogso2bykZG0yiJm5o8zv/sd6pGouayMgkx/2FSOdc36T0jGbCHuRSbtia0PEzNIRtmViMrt3AeoWBidRXmZsxCNLwgIV6dn2WpuE5Az0bHgpZnQxTKFek0BMKU/d8wIDAQABo1AwTjAdBgNVHQ4EFgQUGHxYqZYyX7cTxKVODVgZwSTdCnwwHwYDVR0jBBgwFoAUGHxYqZYyX7cTxKVODVgZwSTdCnwwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQ0FAAOBgQByFOl+hMFICbd3DJfnp2Rgd/dqttsZG/tyhILWvErbio/DEe98mXpowhTkC04ENprOyXi7ZbUqiicF89uAGyt1oqgTUCD1VsLahqIcmrzgumNyTwLGWo17WDAa1/usDhetWAMhgzF/Cnf5ek0nK00m0YZGyc4LzgD0CROMASTWNg== + + _ce3d2948b4cf20146dee0a0b3dd6f69b6cf86f62d7 + + + + + + + http://sp.example.com/demo1/metadata.php + + + + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + + + + test + + + test@example.com + + + users + examplerole1 + + + +` + +// SAMLResponsePostData represents the SAML response data that is expected +// in the form data of a POST request. +type SAMLResponsePostData struct { + SAMLResponse string `json:"saml_response"` + RelayState string `json:"relay_state"` + Destination string `json:"destination"` +} + +// PostRequest creates an http POST request with the SAML response and relay state +// included as form data. +func (s *SAMLResponsePostData) PostRequest(t *testing.T) *http.Request { + t.Helper() + r := require.New(t) + + form := url.Values{} + form.Add("SAMLResponse", s.SAMLResponse) + form.Add("RelayState", s.RelayState) + + req, err := http.NewRequest(http.MethodPost, s.Destination, strings.NewReader(form.Encode())) + r.NoError(err) + + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + return req +} + +// TestProvider is an identity provider that can be used for testing +// SAML federeation and authentication flows. +type TestProvider struct { + t *testing.T + server *httptest.Server + + metadata *metadata.EntityDescriptorIDPSSO + recorder *httptest.ResponseRecorder + result *http.Response + + expectedRelayState string + expectedVersion string + expectedIssuer string + expectedProtocolBinding string + expectedACSURL string + expectedRequestID string + expectInvalidRequestID bool + expectedIssueInstant time.Time + + expectedB64EncSAMLRequest string +} + +func (p *TestProvider) defaults() { + p.expectedVersion = "2.0" + p.expectedProtocolBinding = string(core.ServiceBindingHTTPPost) +} + +// SetExpectedRelayState sets the expected RelayState value. +func (p *TestProvider) SetExpectedRelayState(rs string) { + p.expectedRelayState = rs +} + +// SetExpectedIssuer sets the in the SAML request expected issuer value. +func (p *TestProvider) SetExpectedIssuer(sr string) { + p.expectedIssuer = sr +} + +// SetExpectedProtocolBinding sets the in the SAML request expected protocol binding. +// Defaults to the HTTP-POST binding. +func (p *TestProvider) SetExpectedProtocolBinding(pb string) { + p.expectedProtocolBinding = pb +} + +// SetExpectedACSURL sets the in the SAML request expected assertion consumer service URL. +func (p *TestProvider) SetExpectedACSURL(acs string) { + p.expectedACSURL = acs +} + +// SetExpectedRequestID sets the in the SAML request expected request ID. +func (p *TestProvider) SetExpectedRequestID(id string) { + p.expectedRequestID = id +} + +// ExpectInvalidRequestID expects that the ID isn't XSD:ID conform. +func (p *TestProvider) ExpectedInvalidRequestID() { + p.expectInvalidRequestID = true +} + +// SetExpectedACSURL sets the in the SAML request expected issue instant value. +func (p *TestProvider) SetExpectedIssueInstant(ii time.Time) { + p.expectedIssueInstant = ii +} + +// SetExpectedSAMLRequest sets the expected SAML request. +func (p *TestProvider) SetExpectedBase64EncodedSAMLRequest(sr string) { + p.expectedB64EncSAMLRequest = sr +} + +// StartTestProvider starts a new identity provider for testing. +// The metadata XML is served at the "/saml/metadata" path. +// The server URL can be obtained by calling the ServerURL() method. +// +// The metadata XML contains the HTTP-Post and Redirect sign-on endpoints. +// The sign-on endpoints will validate the incoming requests on their correctness. +// The SAMLResponse, RelayState, and Destination URL will be returned in a JSON file, +// that can be unmarshalled into testprovider.SAMLResponsePostData. +func StartTestProvider(t *testing.T) *TestProvider { + t.Helper() + r := require.New(t) + + var m metadata.EntityDescriptorIDPSSO + err := xml.Unmarshal([]byte(meta), &m) + r.NoError(err) + + provider := &TestProvider{ + t: t, + metadata: &m, + } + + provider.defaults() + + mux := http.NewServeMux() + mux.HandleFunc("/saml/metadata", provider.metadataHandler) + mux.HandleFunc("/saml/login/post", provider.loginHandlerPost) + mux.HandleFunc("/saml/login/redirect", provider.loginHandlerRedirect) + + server := httptest.NewUnstartedServer(mux) + provider.server = server + + server.Start() + + overrideSSOLocations(server.URL, &m) + + return provider +} + +func overrideSSOLocations(serverURL string, metadata *metadata.EntityDescriptorIDPSSO) { + ssoDescriptor := metadata.IDPSSODescriptor[0] + for i, sso := range ssoDescriptor.SingleSignOnService { + if sso.Binding == core.ServiceBindingHTTPPost { + sso.Location = fmt.Sprintf("%s/saml/login/post", serverURL) + ssoDescriptor.SingleSignOnService[i] = sso + } + + if sso.Binding == core.ServiceBindingHTTPRedirect { + sso.Location = fmt.Sprintf("%s/saml/login/redirect", serverURL) + ssoDescriptor.SingleSignOnService[i] = sso + } + } +} + +// Close shut downs the server and waits for all requests to complete. +func (p *TestProvider) Close() { + p.server.Close() +} + +// ServerURL returns the test server URL. +func (p *TestProvider) ServerURL() string { + return p.server.URL +} + +func (p *TestProvider) metadataHandler(w http.ResponseWriter, _ *http.Request) { + p.t.Helper() + r := require.New(p.t) + + err := xml.NewEncoder(w).Encode(p.metadata) + r.NoError(err) +} + +func (p *TestProvider) loginHandlerPost(w http.ResponseWriter, req *http.Request) { + p.t.Helper() + r := require.New(p.t) + + err := req.ParseForm() + r.NoError(err) + + rawReq := req.FormValue("SAMLRequest") + r.NotEmpty(rawReq) + + // do not check the base64 encoded saml request if not explicitly set. + if p.expectedB64EncSAMLRequest != "" { + r.Equal(p.expectedB64EncSAMLRequest, rawReq) + } + + relayState := req.FormValue("RelayState") + + r.Equal(p.expectedRelayState, relayState, "relay state doesn't match") + http.Error(w, "not implemented", http.StatusNotImplemented) + + samlReq := p.parseRequestPost(rawReq) + + p.validateRequest(samlReq) + + samlResponseData := &SAMLResponsePostData{ + SAMLResponse: responseSigned, + RelayState: relayState, + Destination: samlReq.AssertionConsumerServiceURL, + } + + w.Header().Set("Content-Type", "application/json") + + err = json.NewEncoder(w).Encode(samlResponseData) + r.NoError(err, "failed to encode SAML response data") +} + +func (p *TestProvider) loginHandlerRedirect(w http.ResponseWriter, req *http.Request) { + p.t.Helper() + r := require.New(p.t) + + rawReq := req.URL.Query().Get("SAMLRequest") + r.NotEmpty(rawReq) + + // do not check the base64 encoded saml request if not explicitly set. + if p.expectedB64EncSAMLRequest != "" { + r.Equal(p.expectedB64EncSAMLRequest, rawReq) + } + + relayState := req.URL.Query().Get("RelayState") + + r.Equal(p.expectedRelayState, relayState, "relay state doesn't match") + + samlReq := p.parseRequestRedirect(rawReq) + r.NotNil(samlReq, "the saml request must not be nil") + + p.validateRequest(samlReq) + + samlResponseData := &SAMLResponsePostData{ + SAMLResponse: responseSigned, + RelayState: relayState, + Destination: samlReq.AssertionConsumerServiceURL, + } + + w.Header().Set("Content-Type", "application/json") + + err := json.NewEncoder(w).Encode(samlResponseData) + r.NoError(err, "failed to encode SAML response data") +} + +func (p *TestProvider) validateRequest(samlReq *core.AuthnRequest) { + p.t.Helper() + r := require.New(p.t) + + r.Equal( + p.expectedVersion, + samlReq.Version, + fmt.Sprintf("the SAML version doesn't match. Got: %s", samlReq.Version), + ) + + expectedDestination := fmt.Sprintf("%s/saml/login/redirect", p.server.URL) + r.Equal( + expectedDestination, + samlReq.Destination, + "the destination must match the HTTP redirect location from the IDP metadata", + ) + + if p.expectInvalidRequestID { + r.False( + idRegexp.MatchString(samlReq.ID), + "expected an invalid SAML request ID but it's valid", + ) + } else { + r.True( + idRegexp.MatchString(samlReq.ID), + fmt.Sprintf( + "first letter of the SAML request ID must be a letter or underscore. Got: %s", + samlReq.ID, + ), + ) + } + + r.Equal( + p.expectedIssuer, + samlReq.Issuer.Value, + "the issuer value doesn't match the expected issuer", + ) + + r.Equal( + p.expectedProtocolBinding, + string(samlReq.ProtocolBinding), + "SAML protocol binding doesn't match", + ) + + r.Equal( + p.expectedACSURL, + samlReq.AssertionConsumerServiceURL, + "ACS URL doesn't match", + ) + + // TODO: Add an option to set an issue instant + // r.Equal( + // p.expectedIssueInstant, samlReq.IssueInstant, "issue instant doesn't match", + // ) + + if p.expectedRequestID != "" { + r.Equal( + p.expectedRequestID, + samlReq.ID, + "expected request ID doesn't match the ID in the SAML request", + ) + } +} + +func (p *TestProvider) parseRequestRedirect(request string) *core.AuthnRequest { + p.t.Helper() + r := require.New(p.t) + + deflated, err := base64.StdEncoding.DecodeString(request) + r.NoError(err, "couldn't base64 decode SAML request") + + raw, err := io.ReadAll(flate.NewReader(bytes.NewReader(deflated))) + r.NoError(err, "couldn't uncompress (deflated) SAML request") + + req := core.AuthnRequest{} + err = xml.Unmarshal(raw, &req) + r.NoError(err, "couldn't unmarshal SAML request") + + return &req +} + +func (p *TestProvider) parseRequestPost(request string) *core.AuthnRequest { + p.t.Helper() + r := require.New(p.t) + + raw, err := base64.StdEncoding.DecodeString(request) + r.NoError(err, "couldn't base64 decode SAML request") + + req := core.AuthnRequest{} + err = xml.Unmarshal(raw, &req) + r.NoError(err, "couldn't unmarshal SAML request") + + return &req +} diff --git a/tools/tools.go b/tools/tools.go new file mode 100644 index 00000000..5446130d --- /dev/null +++ b/tools/tools.go @@ -0,0 +1,24 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +//go:build tools +// +build tools + +// This file ensures tool dependencies are kept in sync. This is the +// recommended way of doing this according to +// https://github.com/golang/go/wiki/Modules#how-can-i-track-tool-dependencies-for-a-module +// To install the following tools at the version used by this repo run: +// $ make tools +// or +// $ go generate -tags tools tools/tools.go + +package tools + +// NOTE: This must not be indented, so to stop goimports from trying to be +// helpful, it's separated out from the import block below. Please try to keep +// them in the same order. +//go:generate go install mvdan.cc/gofumpt + +import ( + _ "mvdan.cc/gofumpt" +) diff --git a/util/util.go b/util/util.go index f2b5f031..0097e500 100644 --- a/util/util.go +++ b/util/util.go @@ -31,7 +31,7 @@ func IsWSL() (bool, error) { isDocker := strings.Contains(strings.ToLower(string(cgroupData)), "/docker/") isLxc := strings.Contains(strings.ToLower(string(cgroupData)), "/lxc/") isMsLinux := strings.Contains(strings.ToLower(string(procData)), "microsoft") - + return isMsLinux && !(isDocker || isLxc), nil }