git.iko.soy/typeable/oauth2-proxy
on master help sign in
Merge pull request #705 from oauth2-proxy/header-injectors

Add generic Header injectors for upstream request and response headers

commita835fc5192ba64e2a469950dd25dd897eec815dc authorJoel Speed <Joel.speed@hotmail.co.uk> dateWed 7 Oct 2020, 18:40 (6 years ago) parentd8b0d907099032
12 files changed+1298
CHANGELOG.md+1 pkg/apis/options/common.go+14 pkg/apis/options/header.go+44 pkg/apis/options/util/util.go+26 pkg/apis/options/util/util_suite_test.go+16 pkg/apis/options/util/util_test.go+88 pkg/apis/sessions/session_state.go+30 pkg/header/header_suite_test.go+37 pkg/header/injector.go+118 pkg/header/injector_test.go+417 pkg/middleware/headers.go+102 pkg/middleware/headers_test.go+405
CHANGELOG.md+1
@@ -25,6 +25,7 @@
2525
2626## Changes since v6.1.1
2727
28+- [#705](https://github.com/oauth2-proxy/oauth2-proxy/pull/705) Add generic Header injectors for upstream request and response headers (@JoelSpeed)
2829- [#753](https://github.com/oauth2-proxy/oauth2-proxy/pull/753) Pass resource parameter in login url (@codablock)
2930- [#789](https://github.com/oauth2-proxy/oauth2-proxy/pull/789) Add `--skip-auth-route` configuration option for `METHOD=pathRegex` based allowlists (@NickMeves)
3031- [#575](https://github.com/oauth2-proxy/oauth2-proxy/pull/575) Stop accepting legacy SHA1 signed cookies (@NickMeves)
pkg/apis/options/common.gonew file+14
@@ -0,0 +1,14 @@
1+package options
2+
3+// SecretSource references an individual secret value.
4+// Only one source within the struct should be defined at any time.
5+type SecretSource struct {
6+ // Value expects a base64 encoded string value.
7+ Value []byte
8+
9+ // FromEnv expects the name of an environment variable.
10+ FromEnv string
11+
12+ // FromFile expects a path to a file containing the secret value.
13+ FromFile string
14+}
pkg/apis/options/header.gonew file+44
@@ -0,0 +1,44 @@
1+package options
2+
3+// Header represents an individual header that will be added to a request or
4+// response header.
5+type Header struct {
6+ // Name is the header name to be used for this set of values.
7+ // Names should be unique within a list of Headers.
8+ Name string `json:"name"`
9+
10+ // PreserveRequestValue determines whether any values for this header
11+ // should be preserved for the request to the upstream server.
12+ // This option only takes effet on injected request headers.
13+ // Defaults to false (headers that match this header will be stripped).
14+ PreserveRequestValue bool `json:"preserveRequestValue"`
15+
16+ // Values contains the desired values for this header
17+ Values []HeaderValue `json:"values"`
18+}
19+
20+// HeaderValue represents a single header value and the sources that can
21+// make up the header value
22+type HeaderValue struct {
23+ // Allow users to load the value from a secret source
24+ *SecretSource
25+
26+ // Allow users to load the value from a session claim
27+ *ClaimSource
28+}
29+
30+// ClaimSource allows loading a header value from a claim within the session
31+type ClaimSource struct {
32+ // Claim is the name of the claim in the session that the value should be
33+ // loaded from.
34+ Claim string `json:"claim,omitempty"`
35+
36+ // Prefix is an optional prefix that will be prepended to the value of the
37+ // claim if it is non-empty.
38+ Prefix string `json:"prefix,omitempty"`
39+
40+ // BasicAuthPassword converts this claim into a basic auth header.
41+ // Note the value of claim will become the basic auth username and the
42+ // basicAuthPassword will be used as the password value.
43+ BasicAuthPassword *SecretSource
44+}
pkg/apis/options/util/util.gonew file+26
@@ -0,0 +1,26 @@
1+package util
2+
3+import (
4+ "encoding/base64"
5+ "errors"
6+ "io/ioutil"
7+ "os"
8+
9+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
10+)
11+
12+// GetSecretValue returns the value of the Secret from its source
13+func GetSecretValue(source *options.SecretSource) ([]byte, error) {
14+ switch {
15+ case len(source.Value) > 0 && source.FromEnv == "" && source.FromFile == "":
16+ value := make([]byte, base64.StdEncoding.DecodedLen(len(source.Value)))
17+ decoded, err := base64.StdEncoding.Decode(value, source.Value)
18+ return value[:decoded], err
19+ case len(source.Value) == 0 && source.FromEnv != "" && source.FromFile == "":
20+ return []byte(os.Getenv(source.FromEnv)), nil
21+ case len(source.Value) == 0 && source.FromEnv == "" && source.FromFile != "":
22+ return ioutil.ReadFile(source.FromFile)
23+ default:
24+ return nil, errors.New("secret source is invalid: exactly one entry required, specify either value, fromEnv or fromFile")
25+ }
26+}
pkg/apis/options/util/util_suite_test.gonew file+16
@@ -0,0 +1,16 @@
1+package util
2+
3+import (
4+ "testing"
5+
6+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
7+ . "github.com/onsi/ginkgo"
8+ . "github.com/onsi/gomega"
9+)
10+
11+func TestUtilSuite(t *testing.T) {
12+ logger.SetOutput(GinkgoWriter)
13+
14+ RegisterFailHandler(Fail)
15+ RunSpecs(t, "Options Util Suite")
16+}
pkg/apis/options/util/util_test.gonew file+88
@@ -0,0 +1,88 @@
1+package util
2+
3+import (
4+ "encoding/base64"
5+ "io/ioutil"
6+ "os"
7+ "path"
8+
9+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
10+ . "github.com/onsi/ginkgo"
11+ . "github.com/onsi/gomega"
12+)
13+
14+var _ = Describe("GetSecretValue", func() {
15+ var fileDir string
16+ const secretEnvKey = "SECRET_ENV_KEY"
17+ const secretEnvValue = "secret-env-value"
18+ var secretFileValue = []byte("secret-file-value")
19+
20+ BeforeEach(func() {
21+ os.Setenv(secretEnvKey, secretEnvValue)
22+
23+ var err error
24+ fileDir, err = ioutil.TempDir("", "oauth2-proxy-util-get-secret-value")
25+ Expect(err).ToNot(HaveOccurred())
26+ Expect(ioutil.WriteFile(path.Join(fileDir, "secret-file"), secretFileValue, 0600)).To(Succeed())
27+ })
28+
29+ AfterEach(func() {
30+ os.Unsetenv(secretEnvKey)
31+ os.RemoveAll(fileDir)
32+ })
33+
34+ It("returns the correct value from base64", func() {
35+ originalValue := []byte("secret-value-1")
36+ b64Value := base64.StdEncoding.EncodeToString((originalValue))
37+
38+ // Once encoded, the originalValue could have a decoded length longer than
39+ // its actual length, ensure we trim this.
40+ // This assertion ensures we are testing the triming
41+ Expect(len(originalValue)).To(BeNumerically("<", base64.StdEncoding.DecodedLen(len(b64Value))))
42+
43+ value, err := GetSecretValue(&options.SecretSource{
44+ Value: []byte(b64Value),
45+ })
46+ Expect(err).ToNot(HaveOccurred())
47+ Expect(value).To(Equal(originalValue))
48+ })
49+
50+ It("returns the correct value from the environment", func() {
51+ value, err := GetSecretValue(&options.SecretSource{
52+ FromEnv: secretEnvKey,
53+ })
54+ Expect(err).ToNot(HaveOccurred())
55+ Expect(value).To(BeEquivalentTo(secretEnvValue))
56+ })
57+
58+ It("returns the correct value from a file", func() {
59+ value, err := GetSecretValue(&options.SecretSource{
60+ FromFile: path.Join(fileDir, "secret-file"),
61+ })
62+ Expect(err).ToNot(HaveOccurred())
63+ Expect(value).To(Equal(secretFileValue))
64+ })
65+
66+ It("when the file does not exist", func() {
67+ value, err := GetSecretValue(&options.SecretSource{
68+ FromFile: path.Join(fileDir, "not-exist"),
69+ })
70+ Expect(err).To(HaveOccurred())
71+ Expect(value).To(BeEmpty())
72+ })
73+
74+ It("with no source set", func() {
75+ value, err := GetSecretValue(&options.SecretSource{})
76+ Expect(err).To(MatchError("secret source is invalid: exactly one entry required, specify either value, fromEnv or fromFile"))
77+ Expect(value).To(BeEmpty())
78+ })
79+
80+ It("with multiple sources set", func() {
81+ value, err := GetSecretValue(&options.SecretSource{
82+ FromEnv: secretEnvKey,
83+ FromFile: path.Join(fileDir, "secret-file"),
84+ })
85+ Expect(err).To(MatchError("secret source is invalid: exactly one entry required, specify either value, fromEnv or fromFile"))
86+ Expect(value).To(BeEmpty())
87+ })
88+})
pkg/apis/sessions/session_state.go+30
@@ -69,6 +69,36 @@ func (s *SessionState) String() string {
6969 return o + "}"
7070}
7171
72+func (s *SessionState) GetClaim(claim string) []string {
73+ if s == nil {
74+ return []string{}
75+ }
76+ switch claim {
77+ case "access_token":
78+ return []string{s.AccessToken}
79+ case "id_token":
80+ return []string{s.IDToken}
81+ case "created_at":
82+ return []string{s.CreatedAt.String()}
83+ case "expires_on":
84+ return []string{s.ExpiresOn.String()}
85+ case "refresh_token":
86+ return []string{s.RefreshToken}
87+ case "email":
88+ return []string{s.Email}
89+ case "user":
90+ return []string{s.User}
91+ case "groups":
92+ groups := make([]string, len(s.Groups))
93+ copy(groups, s.Groups)
94+ return groups
95+ case "preferred_username":
96+ return []string{s.PreferredUsername}
97+ default:
98+ return []string{}
99+ }
100+}
101+
72102// EncodeSessionState returns an encrypted, lz4 compressed, MessagePack encoded session
73103func (s *SessionState) EncodeSessionState(c encryption.Cipher, compress bool) ([]byte, error) {
74104 packed, err := msgpack.Marshal(s)
pkg/header/header_suite_test.gonew file+37
@@ -0,0 +1,37 @@
1+package header
2+
3+import (
4+ "io/ioutil"
5+ "os"
6+ "path"
7+ "testing"
8+
9+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
10+ . "github.com/onsi/ginkgo"
11+ . "github.com/onsi/gomega"
12+)
13+
14+var (
15+ filesDir string
16+)
17+
18+func TestHeaderSuite(t *testing.T) {
19+ logger.SetOutput(GinkgoWriter)
20+
21+ RegisterFailHandler(Fail)
22+ RunSpecs(t, "Header")
23+}
24+
25+var _ = BeforeSuite(func() {
26+ os.Setenv("SECRET_ENV", "super-secret-env")
27+
28+ dir, err := ioutil.TempDir("", "oauth2-proxy-header-suite")
29+ Expect(err).ToNot(HaveOccurred())
30+ Expect(ioutil.WriteFile(path.Join(dir, "secret-file"), []byte("super-secret-file"), 0644)).To(Succeed())
31+ filesDir = dir
32+})
33+
34+var _ = AfterSuite(func() {
35+ os.Unsetenv("SECRET_ENV")
36+ Expect(os.RemoveAll(filesDir)).To(Succeed())
37+})
pkg/header/injector.gonew file+118
@@ -0,0 +1,118 @@
1+package header
2+
3+import (
4+ "encoding/base64"
5+ "fmt"
6+ "net/http"
7+
8+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
9+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options/util"
10+ sessionsapi "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
11+)
12+
13+type Injector interface {
14+ Inject(http.Header, *sessionsapi.SessionState)
15+}
16+
17+type injector struct {
18+ valueInjectors []valueInjector
19+}
20+
21+func (i injector) Inject(header http.Header, session *sessionsapi.SessionState) {
22+ for _, injector := range i.valueInjectors {
23+ injector.inject(header, session)
24+ }
25+}
26+
27+func NewInjector(headers []options.Header) (Injector, error) {
28+ injectors := []valueInjector{}
29+ for _, header := range headers {
30+ for _, value := range header.Values {
31+ injector, err := newValueinjector(header.Name, value)
32+ if err != nil {
33+ return nil, fmt.Errorf("error building injector for header %q: %v", header.Name, err)
34+ }
35+ injectors = append(injectors, injector)
36+ }
37+ }
38+
39+ return &injector{valueInjectors: injectors}, nil
40+}
41+
42+type valueInjector interface {
43+ inject(http.Header, *sessionsapi.SessionState)
44+}
45+
46+func newValueinjector(name string, value options.HeaderValue) (valueInjector, error) {
47+ switch {
48+ case value.SecretSource != nil && value.ClaimSource == nil:
49+ return newSecretInjector(name, value.SecretSource)
50+ case value.SecretSource == nil && value.ClaimSource != nil:
51+ return newClaimInjector(name, value.ClaimSource)
52+ default:
53+ return nil, fmt.Errorf("header %q value has multiple entries: only one entry per value is allowed", name)
54+ }
55+}
56+
57+type injectorFunc struct {
58+ injectFunc func(http.Header, *sessionsapi.SessionState)
59+}
60+
61+func (i *injectorFunc) inject(header http.Header, session *sessionsapi.SessionState) {
62+ i.injectFunc(header, session)
63+}
64+
65+func newInjectorFunc(injectFunc func(header http.Header, session *sessionsapi.SessionState)) valueInjector {
66+ return &injectorFunc{injectFunc: injectFunc}
67+}
68+
69+func newSecretInjector(name string, source *options.SecretSource) (valueInjector, error) {
70+ value, err := util.GetSecretValue(source)
71+ if err != nil {
72+ return nil, fmt.Errorf("error getting secret value: %v", err)
73+ }
74+
75+ return newInjectorFunc(func(header http.Header, session *sessionsapi.SessionState) {
76+ header.Add(name, string(value))
77+ }), nil
78+}
79+
80+func newClaimInjector(name string, source *options.ClaimSource) (valueInjector, error) {
81+ switch {
82+ case source.BasicAuthPassword != nil:
83+ password, err := util.GetSecretValue(source.BasicAuthPassword)
84+ if err != nil {
85+ return nil, fmt.Errorf("error loading basicAuthPassword: %v", err)
86+ }
87+ return newInjectorFunc(func(header http.Header, session *sessionsapi.SessionState) {
88+ claimValues := session.GetClaim(source.Claim)
89+ for _, claim := range claimValues {
90+ if claim == "" {
91+ continue
92+ }
93+ auth := claim + ":" + string(password)
94+ header.Add(name, "Basic "+base64.StdEncoding.EncodeToString([]byte(auth)))
95+ }
96+ }), nil
97+ case source.Prefix != "":
98+ return newInjectorFunc(func(header http.Header, session *sessionsapi.SessionState) {
99+ claimValues := session.GetClaim(source.Claim)
100+ for _, claim := range claimValues {
101+ if claim == "" {
102+ continue
103+ }
104+ header.Add(name, source.Prefix+claim)
105+ }
106+ }), nil
107+ default:
108+ return newInjectorFunc(func(header http.Header, session *sessionsapi.SessionState) {
109+ claimValues := session.GetClaim(source.Claim)
110+ for _, claim := range claimValues {
111+ if claim == "" {
112+ continue
113+ }
114+ header.Add(name, claim)
115+ }
116+ }), nil
117+ }
118+}
pkg/header/injector_test.gonew file+417
@@ -0,0 +1,417 @@
1+package header
2+
3+import (
4+ "encoding/base64"
5+ "errors"
6+ "net/http"
7+
8+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
9+ sessionsapi "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
10+ . "github.com/onsi/ginkgo"
11+ . "github.com/onsi/ginkgo/extensions/table"
12+ . "github.com/onsi/gomega"
13+)
14+
15+var _ = Describe("Injector Suite", func() {
16+ Context("NewInjector", func() {
17+ type newInjectorTableInput struct {
18+ headers []options.Header
19+ initialHeaders http.Header
20+ session *sessionsapi.SessionState
21+ expectedHeaders http.Header
22+ expectedErr error
23+ }
24+
25+ DescribeTable("creates an injector",
26+ func(in newInjectorTableInput) {
27+ injector, err := NewInjector(in.headers)
28+ if in.expectedErr != nil {
29+ Expect(err).To(MatchError(in.expectedErr))
30+ Expect(injector).To(BeNil())
31+ return
32+ }
33+
34+ Expect(err).ToNot(HaveOccurred())
35+ Expect(injector).ToNot(BeNil())
36+
37+ headers := in.initialHeaders.Clone()
38+ injector.Inject(headers, in.session)
39+ Expect(headers).To(Equal(in.expectedHeaders))
40+ },
41+ Entry("with no configured headers", newInjectorTableInput{
42+ headers: []options.Header{},
43+ initialHeaders: http.Header{
44+ "foo": []string{"bar", "baz"},
45+ },
46+ session: &sessionsapi.SessionState{},
47+ expectedHeaders: http.Header{
48+ "foo": []string{"bar", "baz"},
49+ },
50+ expectedErr: nil,
51+ }),
52+ Entry("with a static valued header from base64", newInjectorTableInput{
53+ headers: []options.Header{
54+ {
55+ Name: "Secret",
56+ Values: []options.HeaderValue{
57+ {
58+ SecretSource: &options.SecretSource{
59+ Value: []byte(base64.StdEncoding.EncodeToString([]byte("super-secret"))),
60+ },
61+ },
62+ },
63+ },
64+ },
65+ initialHeaders: http.Header{
66+ "foo": []string{"bar", "baz"},
67+ },
68+ session: &sessionsapi.SessionState{},
69+ expectedHeaders: http.Header{
70+ "foo": []string{"bar", "baz"},
71+ "Secret": []string{"super-secret"},
72+ },
73+ expectedErr: nil,
74+ }),
75+ Entry("with a static valued header from env", newInjectorTableInput{
76+ headers: []options.Header{
77+ {
78+ Name: "Secret",
79+ Values: []options.HeaderValue{
80+ {
81+ SecretSource: &options.SecretSource{
82+ FromEnv: "SECRET_ENV",
83+ },
84+ },
85+ },
86+ },
87+ },
88+ initialHeaders: http.Header{
89+ "foo": []string{"bar", "baz"},
90+ },
91+ session: &sessionsapi.SessionState{},
92+ expectedHeaders: http.Header{
93+ "foo": []string{"bar", "baz"},
94+ "Secret": []string{"super-secret-env"},
95+ },
96+ expectedErr: nil,
97+ }),
98+ Entry("with a claim valued header", newInjectorTableInput{
99+ headers: []options.Header{
100+ {
101+ Name: "Claim",
102+ Values: []options.HeaderValue{
103+ {
104+ ClaimSource: &options.ClaimSource{
105+ Claim: "id_token",
106+ },
107+ },
108+ },
109+ },
110+ },
111+ initialHeaders: http.Header{
112+ "foo": []string{"bar", "baz"},
113+ },
114+ session: &sessionsapi.SessionState{
115+ IDToken: "IDToken-1234",
116+ },
117+ expectedHeaders: http.Header{
118+ "foo": []string{"bar", "baz"},
119+ "Claim": []string{"IDToken-1234"},
120+ },
121+ expectedErr: nil,
122+ }),
123+ Entry("with a claim valued header and a nil session", newInjectorTableInput{
124+ headers: []options.Header{
125+ {
126+ Name: "Claim",
127+ Values: []options.HeaderValue{
128+ {
129+ ClaimSource: &options.ClaimSource{
130+ Claim: "id_token",
131+ },
132+ },
133+ },
134+ },
135+ },
136+ initialHeaders: http.Header{
137+ "foo": []string{"bar", "baz"},
138+ },
139+ session: nil,
140+ expectedHeaders: http.Header{
141+ "foo": []string{"bar", "baz"},
142+ },
143+ expectedErr: nil,
144+ }),
145+ Entry("with a prefixed claim valued header", newInjectorTableInput{
146+ headers: []options.Header{
147+ {
148+ Name: "Claim",
149+ Values: []options.HeaderValue{
150+ {
151+ ClaimSource: &options.ClaimSource{
152+ Claim: "id_token",
153+ Prefix: "Bearer ",
154+ },
155+ },
156+ },
157+ },
158+ },
159+ initialHeaders: http.Header{
160+ "foo": []string{"bar", "baz"},
161+ },
162+ session: &sessionsapi.SessionState{
163+ IDToken: "IDToken-1234",
164+ },
165+ expectedHeaders: http.Header{
166+ "foo": []string{"bar", "baz"},
167+ "Claim": []string{"Bearer IDToken-1234"},
168+ },
169+ expectedErr: nil,
170+ }),
171+ Entry("with a prefixed claim valued header missing the claim", newInjectorTableInput{
172+ headers: []options.Header{
173+ {
174+ Name: "Claim",
175+ Values: []options.HeaderValue{
176+ {
177+ ClaimSource: &options.ClaimSource{
178+ Claim: "idToken",
179+ Prefix: "Bearer ",
180+ },
181+ },
182+ },
183+ },
184+ },
185+ initialHeaders: http.Header{
186+ "foo": []string{"bar", "baz"},
187+ },
188+ session: &sessionsapi.SessionState{},
189+ expectedHeaders: http.Header{
190+ "foo": []string{"bar", "baz"},
191+ },
192+ expectedErr: nil,
193+ }),
194+ Entry("with a basicAuthPassword and claim valued header", newInjectorTableInput{
195+ headers: []options.Header{
196+ {
197+ Name: "X-Auth-Request-Authorization",
198+ Values: []options.HeaderValue{
199+ {
200+ ClaimSource: &options.ClaimSource{
201+ Claim: "user",
202+ BasicAuthPassword: &options.SecretSource{
203+ Value: []byte(base64.StdEncoding.EncodeToString([]byte("basic-password"))),
204+ },
205+ },
206+ },
207+ },
208+ },
209+ },
210+ initialHeaders: http.Header{
211+ "foo": []string{"bar", "baz"},
212+ },
213+ session: &sessionsapi.SessionState{
214+ User: "user-123",
215+ },
216+ expectedHeaders: http.Header{
217+ "foo": []string{"bar", "baz"},
218+ "X-Auth-Request-Authorization": []string{"Basic " + base64.StdEncoding.EncodeToString([]byte("user-123:basic-password"))},
219+ },
220+ expectedErr: nil,
221+ }),
222+ Entry("with a basicAuthPassword and claim valued header missing the claim", newInjectorTableInput{
223+ headers: []options.Header{
224+ {
225+ Name: "X-Auth-Request-Authorization",
226+ Values: []options.HeaderValue{
227+ {
228+ ClaimSource: &options.ClaimSource{
229+ Claim: "user",
230+ BasicAuthPassword: &options.SecretSource{
231+ Value: []byte(base64.StdEncoding.EncodeToString([]byte("basic-password"))),
232+ },
233+ },
234+ },
235+ },
236+ },
237+ },
238+ initialHeaders: http.Header{
239+ "foo": []string{"bar", "baz"},
240+ },
241+ session: &sessionsapi.SessionState{},
242+ expectedHeaders: http.Header{
243+ "foo": []string{"bar", "baz"},
244+ },
245+ expectedErr: nil,
246+ }),
247+ Entry("with a header that already exists", newInjectorTableInput{
248+ headers: []options.Header{
249+ {
250+ Name: "X-Auth-Request-User",
251+ Values: []options.HeaderValue{
252+ {
253+ ClaimSource: &options.ClaimSource{
254+ Claim: "user",
255+ },
256+ },
257+ },
258+ },
259+ },
260+ initialHeaders: http.Header{
261+ "X-Auth-Request-User": []string{"user"},
262+ },
263+ session: &sessionsapi.SessionState{
264+ User: "user-123",
265+ },
266+ expectedHeaders: http.Header{
267+ "X-Auth-Request-User": []string{"user", "user-123"},
268+ },
269+ expectedErr: nil,
270+ }),
271+ Entry("with a claim and secret valued header value", newInjectorTableInput{
272+ headers: []options.Header{
273+ {
274+ Name: "Claim",
275+ Values: []options.HeaderValue{
276+ {
277+ ClaimSource: &options.ClaimSource{
278+ Claim: "id_token",
279+ },
280+ SecretSource: &options.SecretSource{
281+ FromEnv: "SECRET_ENV",
282+ },
283+ },
284+ },
285+ },
286+ },
287+ initialHeaders: http.Header{
288+ "foo": []string{"bar", "baz"},
289+ },
290+ session: &sessionsapi.SessionState{
291+ IDToken: "IDToken-1234",
292+ },
293+ expectedHeaders: nil,
294+ expectedErr: errors.New("error building injector for header \"Claim\": header \"Claim\" value has multiple entries: only one entry per value is allowed"),
295+ }),
296+ Entry("with an invalid static valued header", newInjectorTableInput{
297+ headers: []options.Header{
298+ {
299+ Name: "Secret",
300+ Values: []options.HeaderValue{
301+ {
302+ SecretSource: &options.SecretSource{
303+ FromEnv: "SECRET_ENV",
304+ FromFile: "secret-file",
305+ },
306+ },
307+ },
308+ },
309+ },
310+ initialHeaders: http.Header{
311+ "foo": []string{"bar", "baz"},
312+ },
313+ session: &sessionsapi.SessionState{},
314+ expectedHeaders: nil,
315+ expectedErr: errors.New("error building injector for header \"Secret\": error getting secret value: secret source is invalid: exactly one entry required, specify either value, fromEnv or fromFile"),
316+ }),
317+ Entry("with an invalid basicAuthPassword claim valued header", newInjectorTableInput{
318+ headers: []options.Header{
319+ {
320+ Name: "X-Auth-Request-Authorization",
321+ Values: []options.HeaderValue{
322+ {
323+ ClaimSource: &options.ClaimSource{
324+ Claim: "user",
325+ BasicAuthPassword: &options.SecretSource{
326+ Value: []byte(base64.StdEncoding.EncodeToString([]byte("basic-password"))),
327+ FromEnv: "SECRET_ENV",
328+ },
329+ },
330+ },
331+ },
332+ },
333+ },
334+ initialHeaders: http.Header{
335+ "foo": []string{"bar", "baz"},
336+ },
337+ session: &sessionsapi.SessionState{
338+ User: "user-123",
339+ },
340+ expectedHeaders: nil,
341+ expectedErr: errors.New("error building injector for header \"X-Auth-Request-Authorization\": error loading basicAuthPassword: secret source is invalid: exactly one entry required, specify either value, fromEnv or fromFile"),
342+ }),
343+ Entry("with a mix of configured headers", newInjectorTableInput{
344+ headers: []options.Header{
345+ {
346+ Name: "X-Auth-Request-Authorization",
347+ Values: []options.HeaderValue{
348+ {
349+ ClaimSource: &options.ClaimSource{
350+ Claim: "user",
351+ BasicAuthPassword: &options.SecretSource{
352+ Value: []byte(base64.StdEncoding.EncodeToString([]byte("basic-password"))),
353+ },
354+ },
355+ },
356+ },
357+ },
358+ {
359+ Name: "X-Auth-Request-User",
360+ Values: []options.HeaderValue{
361+ {
362+ ClaimSource: &options.ClaimSource{
363+ Claim: "user",
364+ },
365+ },
366+ },
367+ },
368+ {
369+ Name: "X-Auth-Request-Email",
370+ Values: []options.HeaderValue{
371+ {
372+ ClaimSource: &options.ClaimSource{
373+ Claim: "email",
374+ },
375+ },
376+ },
377+ },
378+ {
379+ Name: "X-Auth-Request-Version-Info",
380+ Values: []options.HeaderValue{
381+ {
382+ SecretSource: &options.SecretSource{
383+ Value: []byte(base64.StdEncoding.EncodeToString([]byte("major=1"))),
384+ },
385+ },
386+ {
387+ SecretSource: &options.SecretSource{
388+ Value: []byte(base64.StdEncoding.EncodeToString([]byte("minor=2"))),
389+ },
390+ },
391+ {
392+ SecretSource: &options.SecretSource{
393+ Value: []byte(base64.StdEncoding.EncodeToString([]byte("patch=3"))),
394+ },
395+ },
396+ },
397+ },
398+ },
399+ initialHeaders: http.Header{
400+ "foo": []string{"bar", "baz"},
401+ },
402+ session: &sessionsapi.SessionState{
403+ User: "user-123",
404+ Email: "user@example.com",
405+ },
406+ expectedHeaders: http.Header{
407+ "foo": []string{"bar", "baz"},
408+ "X-Auth-Request-Authorization": []string{"Basic " + base64.StdEncoding.EncodeToString([]byte("user-123:basic-password"))},
409+ "X-Auth-Request-User": []string{"user-123"},
410+ "X-Auth-Request-Email": []string{"user@example.com"},
411+ "X-Auth-Request-Version-Info": []string{"major=1", "minor=2", "patch=3"},
412+ },
413+ expectedErr: nil,
414+ }),
415+ )
416+ })
417+})
pkg/middleware/headers.gonew file+102
@@ -0,0 +1,102 @@
1+package middleware
2+
3+import (
4+ "fmt"
5+ "net/http"
6+
7+ "github.com/justinas/alice"
8+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
9+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/header"
10+)
11+
12+func NewRequestHeaderInjector(headers []options.Header) (alice.Constructor, error) {
13+ headerInjector, err := newRequestHeaderInjector(headers)
14+ if err != nil {
15+ return nil, fmt.Errorf("error building request header injector: %v", err)
16+ }
17+
18+ strip := newStripHeaders(headers)
19+ if strip != nil {
20+ return alice.New(strip, headerInjector).Then, nil
21+ }
22+ return headerInjector, nil
23+}
24+
25+func newStripHeaders(headers []options.Header) alice.Constructor {
26+ headersToStrip := []string{}
27+ for _, header := range headers {
28+ if !header.PreserveRequestValue {
29+ headersToStrip = append(headersToStrip, header.Name)
30+ }
31+ }
32+
33+ if len(headersToStrip) == 0 {
34+ return nil
35+ }
36+
37+ return func(next http.Handler) http.Handler {
38+ return stripHeaders(headersToStrip, next)
39+ }
40+}
41+
42+func stripHeaders(headers []string, next http.Handler) http.Handler {
43+ return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
44+ for _, header := range headers {
45+ req.Header.Del(header)
46+ }
47+ next.ServeHTTP(rw, req)
48+ })
49+}
50+
51+func newRequestHeaderInjector(headers []options.Header) (alice.Constructor, error) {
52+ injector, err := header.NewInjector(headers)
53+ if err != nil {
54+ return nil, fmt.Errorf("error building request injector: %v", err)
55+ }
56+
57+ return func(next http.Handler) http.Handler {
58+ return injectRequestHeaders(injector, next)
59+ }, nil
60+}
61+
62+func injectRequestHeaders(injector header.Injector, next http.Handler) http.Handler {
63+ return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
64+ scope := GetRequestScope(req)
65+
66+ // If scope is nil, this will panic.
67+ // A scope should always be injected before this handler is called.
68+ injector.Inject(req.Header, scope.Session)
69+ next.ServeHTTP(rw, req)
70+ })
71+}
72+
73+func NewResponseHeaderInjector(headers []options.Header) (alice.Constructor, error) {
74+ headerInjector, err := newResponseHeaderInjector(headers)
75+ if err != nil {
76+ return nil, fmt.Errorf("error building response header injector: %v", err)
77+ }
78+
79+ return headerInjector, nil
80+}
81+
82+func newResponseHeaderInjector(headers []options.Header) (alice.Constructor, error) {
83+ injector, err := header.NewInjector(headers)
84+ if err != nil {
85+ return nil, fmt.Errorf("error building response injector: %v", err)
86+ }
87+
88+ return func(next http.Handler) http.Handler {
89+ return injectResponseHeaders(injector, next)
90+ }, nil
91+}
92+
93+func injectResponseHeaders(injector header.Injector, next http.Handler) http.Handler {
94+ return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
95+ scope := GetRequestScope(req)
96+
97+ // If scope is nil, this will panic.
98+ // A scope should always be injected before this handler is called.
99+ injector.Inject(rw.Header(), scope.Session)
100+ next.ServeHTTP(rw, req)
101+ })
102+}
pkg/middleware/headers_test.gonew file+405
@@ -0,0 +1,405 @@
1+package middleware
2+
3+import (
4+ "context"
5+ "encoding/base64"
6+ "net/http"
7+ "net/http/httptest"
8+
9+ middlewareapi "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/middleware"
10+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
11+ sessionsapi "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
12+ . "github.com/onsi/ginkgo"
13+ . "github.com/onsi/ginkgo/extensions/table"
14+ . "github.com/onsi/gomega"
15+)
16+
17+var _ = Describe("Headers Suite", func() {
18+ type headersTableInput struct {
19+ headers []options.Header
20+ initialHeaders http.Header
21+ session *sessionsapi.SessionState
22+ expectedHeaders http.Header
23+ expectedErr string
24+ }
25+
26+ DescribeTable("the request header injector",
27+ func(in headersTableInput) {
28+ scope := &middlewareapi.RequestScope{
29+ Session: in.session,
30+ }
31+
32+ // Set up the request with a request scope
33+ req := httptest.NewRequest("", "/", nil)
34+ contextWithScope := context.WithValue(req.Context(), requestScopeKey, scope)
35+ req = req.WithContext(contextWithScope)
36+ req.Header = in.initialHeaders.Clone()
37+
38+ rw := httptest.NewRecorder()
39+
40+ // Create the handler with a next handler that will capture the headers
41+ // from the request
42+ var gotHeaders http.Header
43+ injector, err := NewRequestHeaderInjector(in.headers)
44+ if in.expectedErr != "" {
45+ Expect(err).To(MatchError(in.expectedErr))
46+ return
47+ }
48+ Expect(err).ToNot(HaveOccurred())
49+
50+ handler := injector(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
51+ gotHeaders = r.Header.Clone()
52+ }))
53+ handler.ServeHTTP(rw, req)
54+
55+ Expect(gotHeaders).To(Equal(in.expectedHeaders))
56+ },
57+ Entry("with no configured headers", headersTableInput{
58+ headers: []options.Header{},
59+ initialHeaders: http.Header{
60+ "foo": []string{"bar", "baz"},
61+ },
62+ session: &sessionsapi.SessionState{},
63+ expectedHeaders: http.Header{
64+ "foo": []string{"bar", "baz"},
65+ },
66+ expectedErr: "",
67+ }),
68+ Entry("with a claim valued header", headersTableInput{
69+ headers: []options.Header{
70+ {
71+ Name: "Claim",
72+ Values: []options.HeaderValue{
73+ {
74+ ClaimSource: &options.ClaimSource{
75+ Claim: "id_token",
76+ },
77+ },
78+ },
79+ },
80+ },
81+ initialHeaders: http.Header{
82+ "foo": []string{"bar", "baz"},
83+ },
84+ session: &sessionsapi.SessionState{
85+ IDToken: "IDToken-1234",
86+ },
87+ expectedHeaders: http.Header{
88+ "foo": []string{"bar", "baz"},
89+ "Claim": []string{"IDToken-1234"},
90+ },
91+ expectedErr: "",
92+ }),
93+ Entry("with a claim valued header (without preservation)", headersTableInput{
94+ headers: []options.Header{
95+ {
96+ Name: "Claim",
97+ Values: []options.HeaderValue{
98+ {
99+ ClaimSource: &options.ClaimSource{
100+ Claim: "id_token",
101+ },
102+ },
103+ },
104+ },
105+ },
106+ initialHeaders: http.Header{
107+ "Claim": []string{"bar", "baz"},
108+ },
109+ session: &sessionsapi.SessionState{
110+ IDToken: "IDToken-1234",
111+ },
112+ expectedHeaders: http.Header{
113+ "Claim": []string{"IDToken-1234"},
114+ },
115+ expectedErr: "",
116+ }),
117+ Entry("with a claim valued header (with preservation)", headersTableInput{
118+ headers: []options.Header{
119+ {
120+ Name: "Claim",
121+ PreserveRequestValue: true,
122+ Values: []options.HeaderValue{
123+ {
124+ ClaimSource: &options.ClaimSource{
125+ Claim: "id_token",
126+ },
127+ },
128+ },
129+ },
130+ },
131+ initialHeaders: http.Header{
132+ "Claim": []string{"bar", "baz"},
133+ },
134+ session: &sessionsapi.SessionState{
135+ IDToken: "IDToken-1234",
136+ },
137+ expectedHeaders: http.Header{
138+ "Claim": []string{"bar", "baz", "IDToken-1234"},
139+ },
140+ expectedErr: "",
141+ }),
142+ Entry("with a claim valued header that's not present (without preservation)", headersTableInput{
143+ headers: []options.Header{
144+ {
145+ Name: "Claim",
146+ Values: []options.HeaderValue{
147+ {
148+ ClaimSource: &options.ClaimSource{
149+ Claim: "id_token",
150+ },
151+ },
152+ },
153+ },
154+ },
155+ initialHeaders: http.Header{
156+ "Claim": []string{"bar", "baz"},
157+ },
158+ session: nil,
159+ expectedHeaders: http.Header{},
160+ expectedErr: "",
161+ }),
162+ Entry("with a claim valued header that's not present (with preservation)", headersTableInput{
163+ headers: []options.Header{
164+ {
165+ Name: "Claim",
166+ PreserveRequestValue: true,
167+ Values: []options.HeaderValue{
168+ {
169+ ClaimSource: &options.ClaimSource{
170+ Claim: "id_token",
171+ },
172+ },
173+ },
174+ },
175+ },
176+ initialHeaders: http.Header{
177+ "Claim": []string{"bar", "baz"},
178+ },
179+ session: nil,
180+ expectedHeaders: http.Header{
181+ "Claim": []string{"bar", "baz"},
182+ },
183+ expectedErr: "",
184+ }),
185+ Entry("with an invalid basicAuthPassword claim valued header", headersTableInput{
186+ headers: []options.Header{
187+ {
188+ Name: "X-Auth-Request-Authorization",
189+ Values: []options.HeaderValue{
190+ {
191+ ClaimSource: &options.ClaimSource{
192+ Claim: "user",
193+ BasicAuthPassword: &options.SecretSource{
194+ Value: []byte(base64.StdEncoding.EncodeToString([]byte("basic-password"))),
195+ FromEnv: "SECRET_ENV",
196+ },
197+ },
198+ },
199+ },
200+ },
201+ },
202+ initialHeaders: http.Header{
203+ "foo": []string{"bar", "baz"},
204+ },
205+ session: &sessionsapi.SessionState{
206+ User: "user-123",
207+ },
208+ expectedHeaders: nil,
209+ expectedErr: "error building request header injector: error building request injector: error building injector for header \"X-Auth-Request-Authorization\": error loading basicAuthPassword: secret source is invalid: exactly one entry required, specify either value, fromEnv or fromFile",
210+ }),
211+ )
212+
213+ DescribeTable("the response header injector",
214+ func(in headersTableInput) {
215+ scope := &middlewareapi.RequestScope{
216+ Session: in.session,
217+ }
218+
219+ // Set up the request with a request scope
220+ req := httptest.NewRequest("", "/", nil)
221+ contextWithScope := context.WithValue(req.Context(), requestScopeKey, scope)
222+ req = req.WithContext(contextWithScope)
223+
224+ rw := httptest.NewRecorder()
225+ for key, values := range in.initialHeaders {
226+ for _, value := range values {
227+ rw.Header().Add(key, value)
228+ }
229+ }
230+
231+ // Create the handler with a next handler that will capture the headers
232+ // from the request
233+ var gotHeaders http.Header
234+ injector, err := NewResponseHeaderInjector(in.headers)
235+ if in.expectedErr != "" {
236+ Expect(err).To(MatchError(in.expectedErr))
237+ return
238+ }
239+ Expect(err).ToNot(HaveOccurred())
240+
241+ handler := injector(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
242+ gotHeaders = w.Header().Clone()
243+ }))
244+ handler.ServeHTTP(rw, req)
245+
246+ Expect(gotHeaders).To(Equal(in.expectedHeaders))
247+ },
248+ Entry("with no configured headers", headersTableInput{
249+ headers: []options.Header{},
250+ initialHeaders: http.Header{
251+ "Foo": []string{"bar", "baz"},
252+ },
253+ session: &sessionsapi.SessionState{},
254+ expectedHeaders: http.Header{
255+ "Foo": []string{"bar", "baz"},
256+ },
257+ expectedErr: "",
258+ }),
259+ Entry("with a claim valued header", headersTableInput{
260+ headers: []options.Header{
261+ {
262+ Name: "Claim",
263+ Values: []options.HeaderValue{
264+ {
265+ ClaimSource: &options.ClaimSource{
266+ Claim: "id_token",
267+ },
268+ },
269+ },
270+ },
271+ },
272+ initialHeaders: http.Header{
273+ "Foo": []string{"bar", "baz"},
274+ },
275+ session: &sessionsapi.SessionState{
276+ IDToken: "IDToken-1234",
277+ },
278+ expectedHeaders: http.Header{
279+ "Foo": []string{"bar", "baz"},
280+ "Claim": []string{"IDToken-1234"},
281+ },
282+ expectedErr: "",
283+ }),
284+ Entry("with a claim valued header (without preservation)", headersTableInput{
285+ headers: []options.Header{
286+ {
287+ Name: "Claim",
288+ Values: []options.HeaderValue{
289+ {
290+ ClaimSource: &options.ClaimSource{
291+ Claim: "id_token",
292+ },
293+ },
294+ },
295+ },
296+ },
297+ initialHeaders: http.Header{
298+ "Claim": []string{"bar", "baz"},
299+ },
300+ session: &sessionsapi.SessionState{
301+ IDToken: "IDToken-1234",
302+ },
303+ expectedHeaders: http.Header{
304+ "Claim": []string{"bar", "baz", "IDToken-1234"},
305+ },
306+ expectedErr: "",
307+ }),
308+ Entry("with a claim valued header (with preservation)", headersTableInput{
309+ headers: []options.Header{
310+ {
311+ Name: "Claim",
312+ PreserveRequestValue: true,
313+ Values: []options.HeaderValue{
314+ {
315+ ClaimSource: &options.ClaimSource{
316+ Claim: "id_token",
317+ },
318+ },
319+ },
320+ },
321+ },
322+ initialHeaders: http.Header{
323+ "Claim": []string{"bar", "baz"},
324+ },
325+ session: &sessionsapi.SessionState{
326+ IDToken: "IDToken-1234",
327+ },
328+ expectedHeaders: http.Header{
329+ "Claim": []string{"bar", "baz", "IDToken-1234"},
330+ },
331+ expectedErr: "",
332+ }),
333+ Entry("with a claim valued header that's not present (without preservation)", headersTableInput{
334+ headers: []options.Header{
335+ {
336+ Name: "Claim",
337+ Values: []options.HeaderValue{
338+ {
339+ ClaimSource: &options.ClaimSource{
340+ Claim: "id_token",
341+ },
342+ },
343+ },
344+ },
345+ },
346+ initialHeaders: http.Header{
347+ "Claim": []string{"bar", "baz"},
348+ },
349+ session: nil,
350+ expectedHeaders: http.Header{
351+ "Claim": []string{"bar", "baz"},
352+ },
353+ expectedErr: "",
354+ }),
355+ Entry("with a claim valued header that's not present (with preservation)", headersTableInput{
356+ headers: []options.Header{
357+ {
358+ Name: "Claim",
359+ PreserveRequestValue: true,
360+ Values: []options.HeaderValue{
361+ {
362+ ClaimSource: &options.ClaimSource{
363+ Claim: "id_token",
364+ },
365+ },
366+ },
367+ },
368+ },
369+ initialHeaders: http.Header{
370+ "Claim": []string{"bar", "baz"},
371+ },
372+ session: nil,
373+ expectedHeaders: http.Header{
374+ "Claim": []string{"bar", "baz"},
375+ },
376+ expectedErr: "",
377+ }),
378+ Entry("with an invalid basicAuthPassword claim valued header", headersTableInput{
379+ headers: []options.Header{
380+ {
381+ Name: "X-Auth-Request-Authorization",
382+ Values: []options.HeaderValue{
383+ {
384+ ClaimSource: &options.ClaimSource{
385+ Claim: "user",
386+ BasicAuthPassword: &options.SecretSource{
387+ Value: []byte(base64.StdEncoding.EncodeToString([]byte("basic-password"))),
388+ FromEnv: "SECRET_ENV",
389+ },
390+ },
391+ },
392+ },
393+ },
394+ },
395+ initialHeaders: http.Header{
396+ "foo": []string{"bar", "baz"},
397+ },
398+ session: &sessionsapi.SessionState{
399+ User: "user-123",
400+ },
401+ expectedHeaders: nil,
402+ expectedErr: "error building response header injector: error building response injector: error building injector for header \"X-Auth-Request-Authorization\": error loading basicAuthPassword: secret source is invalid: exactly one entry required, specify either value, fromEnv or fromFile",
403+ }),
404+ )
405+})