git.iko.soy/typeable/oauth2-proxy
on bea7bb1 help sign in
17 files changed+1662−28
since splitsnapshots
unifiedsplit
CHANGELOG.md+1 pkg/apis/options/common.go+14 pkg/apis/options/header.go+44 pkg/apis/options/legacy_options.go+256 pkg/apis/options/options.go+4−28 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 pkg/validation/common.go+43 pkg/validation/header.go+52 pkg/validation/utils.go+9
CHANGELOG.md+1
@@ -22,6 +22,7 @@
2222
2323## Changes since v6.1.1
2424
25+- [#705](https://github.com/oauth2-proxy/oauth2-proxy/pull/705) Add generic Header injectors for upstream request and response headers (@JoelSpeed)
2526- [#753](https://github.com/oauth2-proxy/oauth2-proxy/pull/753) Pass resource parameter in login url (@codablock)
2627- [#575](https://github.com/oauth2-proxy/oauth2-proxy/pull/575) Stop accepting legacy SHA1 signed cookies (@NickMeves)
2728- [#722](https://github.com/oauth2-proxy/oauth2-proxy/pull/722) Validate Redis configuration options at startup (@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/legacy_options.go+256
@@ -1,6 +1,7 @@
11package options
22
33import (
4+ "encoding/base64"
45 "fmt"
56 "net/url"
67 "strconv"
@@ -15,6 +16,9 @@ type LegacyOptions struct {
1516 // Legacy options related to upstream servers
1617 LegacyUpstreams LegacyUpstreams `cfg:",squash"`
1718
19+ // Legacy options for injecting request/response headers
20+ LegacyHeaders LegacyHeaders `cfg:",squash"`
21+
1822 Options Options `cfg:",squash"`
1923}
2024
@@ -26,6 +30,11 @@ func NewLegacyOptions() *LegacyOptions {
2630 FlushInterval: time.Duration(1) * time.Second,
2731 },
2832
33+ LegacyHeaders: LegacyHeaders{
34+ PassBasicAuth: true,
35+ PassUserHeaders: true,
36+ },
37+
2938 Options: *NewOptions(),
3039 }
3140}
@@ -119,3 +128,250 @@ func (l *LegacyUpstreams) convert() (Upstreams, error) {
119128
120129 return upstreams, nil
121130}
131+
132+type LegacyHeaders struct {
133+ PassBasicAuth bool `flag:"pass-basic-auth" cfg:"pass_basic_auth"`
134+ PassAccessToken bool `flag:"pass-access-token" cfg:"pass_access_token"`
135+ PassUserHeaders bool `flag:"pass-user-headers" cfg:"pass_user_headers"`
136+ PassAuthorization bool `flag:"pass-authorization-header" cfg:"pass_authorization_header"`
137+
138+ SetBasicAuth bool `flag:"set-basic-auth" cfg:"set_basic_auth"`
139+ SetXAuthRequest bool `flag:"set-xauthrequest" cfg:"set_xauthrequest"`
140+ SetAuthorization bool `flag:"set-authorization-header" cfg:"set_authorization_header"`
141+
142+ PreferEmailToUser bool `flag:"prefer-email-to-user" cfg:"prefer_email_to_user"`
143+ BasicAuthPassword string `flag:"basic-auth-password" cfg:"basic_auth_password"`
144+ SkipAuthStripHeaders bool `flag:"skip-auth-strip-headers" cfg:"skip_auth_strip_headers"`
145+}
146+
147+func legacyHeadersFlagSet() *pflag.FlagSet {
148+ flagSet := pflag.NewFlagSet("headers", pflag.ExitOnError)
149+
150+ flagSet.Bool("pass-basic-auth", true, "pass HTTP Basic Auth, X-Forwarded-User and X-Forwarded-Email information to upstream")
151+ flagSet.Bool("pass-access-token", false, "pass OAuth access_token to upstream via X-Forwarded-Access-Token header")
152+ flagSet.Bool("pass-user-headers", true, "pass X-Forwarded-User and X-Forwarded-Email information to upstream")
153+ flagSet.Bool("pass-authorization-header", false, "pass the Authorization Header to upstream")
154+
155+ flagSet.Bool("set-basic-auth", false, "set HTTP Basic Auth information in response (useful in Nginx auth_request mode)")
156+ flagSet.Bool("set-xauthrequest", false, "set X-Auth-Request-User and X-Auth-Request-Email response headers (useful in Nginx auth_request mode)")
157+ flagSet.Bool("set-authorization-header", false, "set Authorization response headers (useful in Nginx auth_request mode)")
158+
159+ flagSet.Bool("prefer-email-to-user", false, "Prefer to use the Email address as the Username when passing information to upstream. Will only use Username if Email is unavailable, eg. htaccess authentication. Used in conjunction with -pass-basic-auth and -pass-user-headers")
160+ flagSet.String("basic-auth-password", "", "the password to set when passing the HTTP Basic Auth header")
161+ flagSet.Bool("skip-auth-strip-headers", false, "strips X-Forwarded-* style authentication headers & Authorization header if they would be set by oauth2-proxy for request paths in --skip-auth-regex")
162+
163+ return flagSet
164+}
165+
166+// convert takes the legacy request/response headers and converts them to
167+// the new format for InjectRequestHeaders and InjectResponseHeaders
168+func (l *LegacyHeaders) convert() ([]Header, []Header) {
169+ return l.getRequestHeaders(), l.getResponseHeaders()
170+}
171+
172+func (l *LegacyHeaders) getRequestHeaders() []Header {
173+ requestHeaders := []Header{}
174+
175+ if l.PassBasicAuth {
176+ requestHeaders = append(requestHeaders, getBasicAuthHeader(l.PreferEmailToUser, l.BasicAuthPassword))
177+ }
178+
179+ // In the old implementation, PassUserHeaders is a subset of PassBasicAuth
180+ if l.PassBasicAuth || l.PassUserHeaders {
181+ requestHeaders = append(requestHeaders, getPassUserHeaders(l.PreferEmailToUser)...)
182+ requestHeaders = append(requestHeaders, getPreferredUsernameHeader())
183+ }
184+
185+ if l.PassAccessToken {
186+ requestHeaders = append(requestHeaders, getPassAccessTokenHeader())
187+ }
188+
189+ if l.PassAuthorization {
190+ requestHeaders = append(requestHeaders, getAuthorizationHeader())
191+ }
192+
193+ return requestHeaders
194+}
195+
196+func (l *LegacyHeaders) getResponseHeaders() []Header {
197+ responseHeaders := []Header{}
198+
199+ if l.SetXAuthRequest {
200+ responseHeaders = append(responseHeaders, getXAuthRequestHeaders(l.PassAccessToken)...)
201+ }
202+
203+ if l.SetBasicAuth {
204+ responseHeaders = append(responseHeaders, getBasicAuthHeader(l.PreferEmailToUser, l.BasicAuthPassword))
205+ }
206+
207+ if l.SetAuthorization {
208+ responseHeaders = append(responseHeaders, getAuthorizationHeader())
209+ }
210+
211+ return responseHeaders
212+}
213+
214+func getBasicAuthHeader(preferEmailToUser bool, basicAuthPassword string) Header {
215+ claim := "user"
216+ if preferEmailToUser {
217+ claim = "email"
218+ }
219+
220+ return Header{
221+ Name: "Authorization",
222+ Values: []HeaderValue{
223+ {
224+ ClaimSource: &ClaimSource{
225+ Claim: claim,
226+ BasicAuthPassword: &SecretSource{
227+ Value: []byte(base64.StdEncoding.EncodeToString([]byte(basicAuthPassword))),
228+ },
229+ },
230+ },
231+ },
232+ }
233+}
234+
235+func getPassUserHeaders(preferEmailToUser bool) []Header {
236+ headers := []Header{
237+ {
238+ Name: "X-Forwarded-Groups",
239+ Values: []HeaderValue{
240+ {
241+ ClaimSource: &ClaimSource{
242+ Claim: "groups",
243+ },
244+ },
245+ },
246+ },
247+ }
248+
249+ if preferEmailToUser {
250+ return append(headers,
251+ Header{
252+ Name: "X-Forwarded-User",
253+ Values: []HeaderValue{
254+ {
255+ ClaimSource: &ClaimSource{
256+ Claim: "email",
257+ },
258+ },
259+ },
260+ },
261+ )
262+ } else {
263+ return append(headers,
264+ Header{
265+ Name: "X-Forwarded-User",
266+ Values: []HeaderValue{
267+ {
268+ ClaimSource: &ClaimSource{
269+ Claim: "user",
270+ },
271+ },
272+ },
273+ },
274+ Header{
275+ Name: "X-Forwarded-Email",
276+ Values: []HeaderValue{
277+ {
278+ ClaimSource: &ClaimSource{
279+ Claim: "email",
280+ },
281+ },
282+ },
283+ },
284+ )
285+ }
286+}
287+
288+func getPassAccessTokenHeader() Header {
289+ return Header{
290+ Name: "X-Forwarded-Access-Token",
291+ Values: []HeaderValue{
292+ {
293+ ClaimSource: &ClaimSource{
294+ Claim: "access_token",
295+ },
296+ },
297+ },
298+ }
299+}
300+
301+func getAuthorizationHeader() Header {
302+ return Header{
303+ Name: "Authorization",
304+ Values: []HeaderValue{
305+ {
306+ ClaimSource: &ClaimSource{
307+ Claim: "id_token",
308+ Prefix: "Bearer ",
309+ },
310+ },
311+ },
312+ }
313+}
314+
315+func getPreferredUsernameHeader() Header {
316+ return Header{
317+ Name: "X-Forwarded-Preferred-Username",
318+ Values: []HeaderValue{
319+ {
320+ ClaimSource: &ClaimSource{
321+ Claim: "preferred_username",
322+ },
323+ },
324+ },
325+ }
326+}
327+
328+func getXAuthRequestHeaders(passAccessToken bool) []Header {
329+ headers := []Header{
330+ {
331+ Name: "X-Auth-Request-User",
332+ Values: []HeaderValue{
333+ {
334+ ClaimSource: &ClaimSource{
335+ Claim: "user",
336+ },
337+ },
338+ },
339+ },
340+ {
341+ Name: "X-Auth-Request-Email",
342+ Values: []HeaderValue{
343+ {
344+ ClaimSource: &ClaimSource{
345+ Claim: "email",
346+ },
347+ },
348+ },
349+ },
350+ getPreferredUsernameHeader(),
351+ {
352+ Name: "X-Auth-Request-Groups",
353+ Values: []HeaderValue{
354+ {
355+ ClaimSource: &ClaimSource{
356+ Claim: "groups",
357+ },
358+ },
359+ },
360+ },
361+ }
362+
363+ if passAccessToken {
364+ headers = append(headers, Header{
365+ Name: "X-Auth-Request-Access-Token",
366+ Values: []HeaderValue{
367+ {
368+ ClaimSource: &ClaimSource{
369+ Claim: "access_token",
370+ },
371+ },
372+ },
373+ })
374+ }
375+
376+ return headers
377+}
pkg/apis/options/options.go+4−28
@@ -66,21 +66,14 @@ type Options struct {
6666 // TODO(JoelSpeed): Rename when legacy config is removed
6767 UpstreamServers Upstreams `cfg:",internal"`
6868
69+ InjectRequestHeaders []Header `cfg:",internal"`
70+ InjectResponseHeaders []Header `cfg:",internal"`
71+
6972 SkipAuthRegex []string `flag:"skip-auth-regex" cfg:"skip_auth_regex"`
70− SkipAuthStripHeaders bool `flag:"skip-auth-strip-headers" cfg:"skip_auth_strip_headers"`
7173 SkipJwtBearerTokens bool `flag:"skip-jwt-bearer-tokens" cfg:"skip_jwt_bearer_tokens"`
7274 ExtraJwtIssuers []string `flag:"extra-jwt-issuers" cfg:"extra_jwt_issuers"`
73− PassBasicAuth bool `flag:"pass-basic-auth" cfg:"pass_basic_auth"`
74− SetBasicAuth bool `flag:"set-basic-auth" cfg:"set_basic_auth"`
75− PreferEmailToUser bool `flag:"prefer-email-to-user" cfg:"prefer_email_to_user"`
76− BasicAuthPassword string `flag:"basic-auth-password" cfg:"basic_auth_password"`
77− PassAccessToken bool `flag:"pass-access-token" cfg:"pass_access_token"`
7875 SkipProviderButton bool `flag:"skip-provider-button" cfg:"skip_provider_button"`
79− PassUserHeaders bool `flag:"pass-user-headers" cfg:"pass_user_headers"`
8076 SSLInsecureSkipVerify bool `flag:"ssl-insecure-skip-verify" cfg:"ssl_insecure_skip_verify"`
81− SetXAuthRequest bool `flag:"set-xauthrequest" cfg:"set_xauthrequest"`
82− SetAuthorization bool `flag:"set-authorization-header" cfg:"set_authorization_header"`
83− PassAuthorization bool `flag:"pass-authorization-header" cfg:"pass_authorization_header"`
8477 SkipAuthPreflight bool `flag:"skip-auth-preflight" cfg:"skip_auth_preflight"`
8578
8679 // These options allow for other providers besides Google, with
@@ -154,15 +147,7 @@ func NewOptions() *Options {
154147 Cookie: cookieDefaults(),
155148 Session: sessionOptionsDefaults(),
156149 AzureTenant: "common",
157− SetXAuthRequest: false,
158150 SkipAuthPreflight: false,
159− PassBasicAuth: true,
160− SetBasicAuth: false,
161− PassUserHeaders: true,
162− PassAccessToken: false,
163− SetAuthorization: false,
164− PassAuthorization: false,
165− PreferEmailToUser: false,
166151 Prompt: "", // Change to "login" when ApprovalPrompt officially deprecated
167152 ApprovalPrompt: "force",
168153 UserIDClaim: "email",
@@ -186,17 +171,7 @@ func NewFlagSet() *pflag.FlagSet {
186171 flagSet.String("tls-cert-file", "", "path to certificate file")
187172 flagSet.String("tls-key-file", "", "path to private key file")
188173 flagSet.String("redirect-url", "", "the OAuth Redirect URL. ie: \"https://internalapp.yourcompany.com/oauth2/callback\"")
189− flagSet.Bool("set-xauthrequest", false, "set X-Auth-Request-User and X-Auth-Request-Email response headers (useful in Nginx auth_request mode)")
190− flagSet.Bool("pass-basic-auth", true, "pass HTTP Basic Auth, X-Forwarded-User and X-Forwarded-Email information to upstream")
191− flagSet.Bool("set-basic-auth", false, "set HTTP Basic Auth information in response (useful in Nginx auth_request mode)")
192− flagSet.Bool("prefer-email-to-user", false, "Prefer to use the Email address as the Username when passing information to upstream. Will only use Username if Email is unavailable, eg. htaccess authentication. Used in conjunction with -pass-basic-auth and -pass-user-headers")
193− flagSet.Bool("pass-user-headers", true, "pass X-Forwarded-User and X-Forwarded-Email information to upstream")
194− flagSet.String("basic-auth-password", "", "the password to set when passing the HTTP Basic Auth header")
195− flagSet.Bool("pass-access-token", false, "pass OAuth access_token to upstream via X-Forwarded-Access-Token header")
196− flagSet.Bool("pass-authorization-header", false, "pass the Authorization Header to upstream")
197− flagSet.Bool("set-authorization-header", false, "set Authorization response headers (useful in Nginx auth_request mode)")
198174 flagSet.StringSlice("skip-auth-regex", []string{}, "bypass authentication for requests path's that match (may be given multiple times)")
199− flagSet.Bool("skip-auth-strip-headers", false, "strips X-Forwarded-* style authentication headers & Authorization header if they would be set by oauth2-proxy for request paths in --skip-auth-regex")
200175 flagSet.Bool("skip-provider-button", false, "will skip sign-in-page to directly reach the next step: oauth/start")
201176 flagSet.Bool("skip-auth-preflight", false, "will skip authentication for OPTIONS requests")
202177 flagSet.Bool("ssl-insecure-skip-verify", false, "skip validation of certificates presented when using HTTPS providers")
@@ -274,6 +249,7 @@ func NewFlagSet() *pflag.FlagSet {
274249 flagSet.AddFlagSet(cookieFlagSet())
275250 flagSet.AddFlagSet(loggingFlagSet())
276251 flagSet.AddFlagSet(legacyUpstreamsFlagSet())
252+ flagSet.AddFlagSet(legacyHeadersFlagSet())
277253
278254 return flagSet
279255}
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+})
pkg/validation/common.gonew file+43
@@ -0,0 +1,43 @@
1+package validation
2+
3+import (
4+ "encoding/base64"
5+ "fmt"
6+ "os"
7+
8+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
9+)
10+
11+func validateSecretSource(source options.SecretSource) string {
12+ switch {
13+ case len(source.Value) > 0 && source.FromEnv == "" && source.FromFile == "":
14+ return validateSecretSourceValue(source.Value)
15+ case len(source.Value) == 0 && source.FromEnv != "" && source.FromFile == "":
16+ return validateSecretSourceEnv(source.FromEnv)
17+ case len(source.Value) == 0 && source.FromEnv == "" && source.FromFile != "":
18+ return validateSecretSourceFile(source.FromFile)
19+ default:
20+ return "multiple values specified for secret source: specify either value, fromEnv of fromFile"
21+ }
22+}
23+
24+func validateSecretSourceValue(value []byte) string {
25+ if _, err := base64.StdEncoding.Decode([]byte{}, value); err != nil {
26+ return fmt.Sprintf("error decoding secret value: %v", err)
27+ }
28+ return ""
29+}
30+
31+func validateSecretSourceEnv(key string) string {
32+ if value := os.Getenv(key); value == "" {
33+ return fmt.Sprintf("error loading secret from environent: no value for for key %q", key)
34+ }
35+ return ""
36+}
37+
38+func validateSecretSourceFile(path string) string {
39+ if _, err := os.Stat(path); err != nil {
40+ return fmt.Sprintf("error loadig secret from file: %v", err)
41+ }
42+ return ""
43+}
pkg/validation/header.gonew file+52
@@ -0,0 +1,52 @@
1+package validation
2+
3+import (
4+ "fmt"
5+
6+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
7+)
8+
9+func validateHeaders(headers []options.Header) []string {
10+ msgs := []string{}
11+ names := make(map[string]struct{})
12+
13+ for _, header := range headers {
14+ msgs = append(msgs, validateHeader(header, names)...)
15+ }
16+ return msgs
17+}
18+
19+func validateHeader(header options.Header, names map[string]struct{}) []string {
20+ msgs := []string{}
21+
22+ if header.Name == "" {
23+ msgs = append(msgs, "header has empty name: names are required for all headers")
24+ }
25+
26+ if _, ok := names[header.Name]; ok {
27+ msgs = append(msgs, fmt.Sprintf("multiple headers found with name %q: header names must be unique", header.Name))
28+ }
29+
30+ for _, value := range header.Values {
31+ msgs = append(msgs, validateHeaderValue(header.Name, value)...)
32+ }
33+ return msgs
34+}
35+
36+func validateHeaderValue(name string, value options.HeaderValue) []string {
37+ switch {
38+ case value.SecretSource != nil && value.ClaimSource == nil:
39+ return prefixValues("invalid header value: ", validateSecretSource(*value.SecretSource))
40+ case value.SecretSource == nil && value.ClaimSource != nil:
41+ return validateHeaderValueClaimSource(*value.ClaimSource)
42+ default:
43+ return []string{fmt.Sprintf("header %q value has multiple entries: only one entry per value is allowed", name)}
44+ }
45+}
46+
47+func validateHeaderValueClaimSource(claim options.ClaimSource) []string {
48+ if claim.BasicAuthPassword != nil {
49+ return prefixValues("invalid basicAuthPassword: ", validateSecretSource(*claim.BasicAuthPassword))
50+ }
51+ return []string{}
52+}
pkg/validation/utils.gonew file+9
@@ -0,0 +1,9 @@
1+package validation
2+
3+func prefixValues(prefix string, values ...string) []string {
4+ msgs := make([]string, len(values))
5+ for i, value := range values {
6+ msgs[i] = prefix + value
7+ }
8+ return msgs
9+}