diff --git a/pkg/apis/options/util/util.go b/pkg/apis/options/util/util.go new file mode 100644 index 00000000..918da13a --- /dev/null +++ b/pkg/apis/options/util/util.go @@ -0,0 +1,26 @@ +package util + +import ( + "encoding/base64" + "errors" + "io/ioutil" + "os" + + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" +) + +// GetSecretValue returns the value of the Secret from its source +func GetSecretValue(source *options.SecretSource) ([]byte, error) { + switch { + case len(source.Value) > 0 && source.FromEnv == "" && source.FromFile == "": + value := make([]byte, base64.StdEncoding.DecodedLen(len(source.Value))) + decoded, err := base64.StdEncoding.Decode(value, source.Value) + return value[:decoded], err + case len(source.Value) == 0 && source.FromEnv != "" && source.FromFile == "": + return []byte(os.Getenv(source.FromEnv)), nil + case len(source.Value) == 0 && source.FromEnv == "" && source.FromFile != "": + return ioutil.ReadFile(source.FromFile) + default: + return nil, errors.New("secret source is invalid: exactly one entry required, specify either value, fromEnv or fromFile") + } +} diff --git a/pkg/apis/options/util/util_suite_test.go b/pkg/apis/options/util/util_suite_test.go new file mode 100644 index 00000000..75f53dbb --- /dev/null +++ b/pkg/apis/options/util/util_suite_test.go @@ -0,0 +1,16 @@ +package util + +import ( + "testing" + + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestUtilSuite(t *testing.T) { + logger.SetOutput(GinkgoWriter) + + RegisterFailHandler(Fail) + RunSpecs(t, "Options Util Suite") +} diff --git a/pkg/apis/options/util/util_test.go b/pkg/apis/options/util/util_test.go new file mode 100644 index 00000000..5ca76a04 --- /dev/null +++ b/pkg/apis/options/util/util_test.go @@ -0,0 +1,88 @@ +package util + +import ( + "encoding/base64" + "io/ioutil" + "os" + "path" + + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("GetSecretValue", func() { + var fileDir string + const secretEnvKey = "SECRET_ENV_KEY" + const secretEnvValue = "secret-env-value" + var secretFileValue = []byte("secret-file-value") + + BeforeEach(func() { + os.Setenv(secretEnvKey, secretEnvValue) + + var err error + fileDir, err = ioutil.TempDir("", "oauth2-proxy-util-get-secret-value") + Expect(err).ToNot(HaveOccurred()) + Expect(ioutil.WriteFile(path.Join(fileDir, "secret-file"), secretFileValue, 0600)).To(Succeed()) + }) + + AfterEach(func() { + os.Unsetenv(secretEnvKey) + os.RemoveAll(fileDir) + }) + + It("returns the correct value from base64", func() { + originalValue := []byte("secret-value-1") + b64Value := base64.StdEncoding.EncodeToString((originalValue)) + + // Once encoded, the originalValue could have a decoded length longer than + // its actual length, ensure we trim this. + // This assertion ensures we are testing the triming + Expect(len(originalValue)).To(BeNumerically("<", base64.StdEncoding.DecodedLen(len(b64Value)))) + + value, err := GetSecretValue(&options.SecretSource{ + Value: []byte(b64Value), + }) + Expect(err).ToNot(HaveOccurred()) + Expect(value).To(Equal(originalValue)) + }) + + It("returns the correct value from the environment", func() { + value, err := GetSecretValue(&options.SecretSource{ + FromEnv: secretEnvKey, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(value).To(BeEquivalentTo(secretEnvValue)) + }) + + It("returns the correct value from a file", func() { + value, err := GetSecretValue(&options.SecretSource{ + FromFile: path.Join(fileDir, "secret-file"), + }) + Expect(err).ToNot(HaveOccurred()) + Expect(value).To(Equal(secretFileValue)) + }) + + It("when the file does not exist", func() { + value, err := GetSecretValue(&options.SecretSource{ + FromFile: path.Join(fileDir, "not-exist"), + }) + Expect(err).To(HaveOccurred()) + Expect(value).To(BeEmpty()) + }) + + It("with no source set", func() { + value, err := GetSecretValue(&options.SecretSource{}) + Expect(err).To(MatchError("secret source is invalid: exactly one entry required, specify either value, fromEnv or fromFile")) + Expect(value).To(BeEmpty()) + }) + + It("with multiple sources set", func() { + value, err := GetSecretValue(&options.SecretSource{ + FromEnv: secretEnvKey, + FromFile: path.Join(fileDir, "secret-file"), + }) + Expect(err).To(MatchError("secret source is invalid: exactly one entry required, specify either value, fromEnv or fromFile")) + Expect(value).To(BeEmpty()) + }) +}) diff --git a/pkg/apis/sessions/session_state.go b/pkg/apis/sessions/session_state.go index c3db8994..3f675135 100644 --- a/pkg/apis/sessions/session_state.go +++ b/pkg/apis/sessions/session_state.go @@ -8,6 +8,7 @@ import ( "io" "io/ioutil" "reflect" + "strings" "time" "unicode/utf8" @@ -69,6 +70,34 @@ func (s *SessionState) String() string { return o + "}" } +func (s *SessionState) GetClaim(claim string) string { + if s == nil { + return "" + } + switch claim { + case "access_token": + return s.AccessToken + case "id_token": + return s.IDToken + case "created_at": + return s.CreatedAt.String() + case "expires_on": + return s.ExpiresOn.String() + case "refresh_token": + return s.RefreshToken + case "email": + return s.Email + case "user": + return s.User + case "groups": + return strings.Join(s.Groups, ",") + case "preferred_username": + return s.PreferredUsername + default: + return "" + } +} + // EncodeSessionState returns an encrypted, lz4 compressed, MessagePack encoded session func (s *SessionState) EncodeSessionState(c encryption.Cipher, compress bool) ([]byte, error) { packed, err := msgpack.Marshal(s) diff --git a/pkg/header/header_suite_test.go b/pkg/header/header_suite_test.go new file mode 100644 index 00000000..3d05cd02 --- /dev/null +++ b/pkg/header/header_suite_test.go @@ -0,0 +1,37 @@ +package header + +import ( + "io/ioutil" + "os" + "path" + "testing" + + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var ( + filesDir string +) + +func TestHeaderSuite(t *testing.T) { + logger.SetOutput(GinkgoWriter) + + RegisterFailHandler(Fail) + RunSpecs(t, "Header") +} + +var _ = BeforeSuite(func() { + os.Setenv("SECRET_ENV", "super-secret-env") + + dir, err := ioutil.TempDir("", "oauth2-proxy-header-suite") + Expect(err).ToNot(HaveOccurred()) + Expect(ioutil.WriteFile(path.Join(dir, "secret-file"), []byte("super-secret-file"), 0644)).To(Succeed()) + filesDir = dir +}) + +var _ = AfterSuite(func() { + os.Unsetenv("SECRET_ENV") + Expect(os.RemoveAll(filesDir)).To(Succeed()) +}) diff --git a/pkg/header/injector.go b/pkg/header/injector.go new file mode 100644 index 00000000..136185e5 --- /dev/null +++ b/pkg/header/injector.go @@ -0,0 +1,112 @@ +package header + +import ( + "encoding/base64" + "fmt" + "net/http" + + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options/util" + sessionsapi "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" +) + +type Injector interface { + Inject(http.Header, *sessionsapi.SessionState) +} + +type injector struct { + valueInjectors []valueInjector +} + +func (i injector) Inject(header http.Header, session *sessionsapi.SessionState) { + for _, injector := range i.valueInjectors { + injector.inject(header, session) + } +} + +func NewInjector(headers []options.Header) (Injector, error) { + injectors := []valueInjector{} + for _, header := range headers { + for _, value := range header.Values { + injector, err := newValueinjector(header.Name, value) + if err != nil { + return nil, fmt.Errorf("error building injector for header %q: %v", header.Name, err) + } + injectors = append(injectors, injector) + } + } + + return &injector{valueInjectors: injectors}, nil +} + +type valueInjector interface { + inject(http.Header, *sessionsapi.SessionState) +} + +func newValueinjector(name string, value options.HeaderValue) (valueInjector, error) { + switch { + case value.SecretSource != nil && value.ClaimSource == nil: + return newSecretInjector(name, value.SecretSource) + case value.SecretSource == nil && value.ClaimSource != nil: + return newClaimInjector(name, value.ClaimSource) + default: + return nil, fmt.Errorf("header %q value has multiple entries: only one entry per value is allowed", name) + } +} + +type injectorFunc struct { + injectFunc func(http.Header, *sessionsapi.SessionState) +} + +func (i *injectorFunc) inject(header http.Header, session *sessionsapi.SessionState) { + i.injectFunc(header, session) +} + +func newInjectorFunc(injectFunc func(header http.Header, session *sessionsapi.SessionState)) valueInjector { + return &injectorFunc{injectFunc: injectFunc} +} + +func newSecretInjector(name string, source *options.SecretSource) (valueInjector, error) { + value, err := util.GetSecretValue(source) + if err != nil { + return nil, fmt.Errorf("error getting secret value: %v", err) + } + + return newInjectorFunc(func(header http.Header, session *sessionsapi.SessionState) { + header.Add(name, string(value)) + }), nil +} + +func newClaimInjector(name string, source *options.ClaimSource) (valueInjector, error) { + switch { + case source.BasicAuthPassword != nil: + password, err := util.GetSecretValue(source.BasicAuthPassword) + if err != nil { + return nil, fmt.Errorf("error loading basicAuthPassword: %v", err) + } + return newInjectorFunc(func(header http.Header, session *sessionsapi.SessionState) { + claim := session.GetClaim(source.Claim) + if claim == "" { + return + } + auth := claim + ":" + string(password) + header.Add(name, "Basic "+base64.StdEncoding.EncodeToString([]byte(auth))) + }), nil + case source.Prefix != "": + return newInjectorFunc(func(header http.Header, session *sessionsapi.SessionState) { + claim := session.GetClaim(source.Claim) + if claim == "" { + return + } + header.Add(name, source.Prefix+claim) + }), nil + default: + return newInjectorFunc(func(header http.Header, session *sessionsapi.SessionState) { + claim := session.GetClaim(source.Claim) + if claim == "" { + return + } + header.Add(name, claim) + }), nil + } +} diff --git a/pkg/header/injector_test.go b/pkg/header/injector_test.go new file mode 100644 index 00000000..af034fd9 --- /dev/null +++ b/pkg/header/injector_test.go @@ -0,0 +1,417 @@ +package header + +import ( + "encoding/base64" + "errors" + "net/http" + + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" + sessionsapi "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" + . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/extensions/table" + . "github.com/onsi/gomega" +) + +var _ = Describe("Injector Suite", func() { + Context("NewInjector", func() { + type newInjectorTableInput struct { + headers []options.Header + initialHeaders http.Header + session *sessionsapi.SessionState + expectedHeaders http.Header + expectedErr error + } + + DescribeTable("creates an injector", + func(in newInjectorTableInput) { + injector, err := NewInjector(in.headers) + if in.expectedErr != nil { + Expect(err).To(MatchError(in.expectedErr)) + Expect(injector).To(BeNil()) + return + } + + Expect(err).ToNot(HaveOccurred()) + Expect(injector).ToNot(BeNil()) + + headers := in.initialHeaders.Clone() + injector.Inject(headers, in.session) + Expect(headers).To(Equal(in.expectedHeaders)) + }, + Entry("with no configured headers", newInjectorTableInput{ + headers: []options.Header{}, + initialHeaders: http.Header{ + "foo": []string{"bar", "baz"}, + }, + session: &sessionsapi.SessionState{}, + expectedHeaders: http.Header{ + "foo": []string{"bar", "baz"}, + }, + expectedErr: nil, + }), + Entry("with a static valued header from base64", newInjectorTableInput{ + headers: []options.Header{ + { + Name: "Secret", + Values: []options.HeaderValue{ + { + SecretSource: &options.SecretSource{ + Value: []byte(base64.StdEncoding.EncodeToString([]byte("super-secret"))), + }, + }, + }, + }, + }, + initialHeaders: http.Header{ + "foo": []string{"bar", "baz"}, + }, + session: &sessionsapi.SessionState{}, + expectedHeaders: http.Header{ + "foo": []string{"bar", "baz"}, + "Secret": []string{"super-secret"}, + }, + expectedErr: nil, + }), + Entry("with a static valued header from env", newInjectorTableInput{ + headers: []options.Header{ + { + Name: "Secret", + Values: []options.HeaderValue{ + { + SecretSource: &options.SecretSource{ + FromEnv: "SECRET_ENV", + }, + }, + }, + }, + }, + initialHeaders: http.Header{ + "foo": []string{"bar", "baz"}, + }, + session: &sessionsapi.SessionState{}, + expectedHeaders: http.Header{ + "foo": []string{"bar", "baz"}, + "Secret": []string{"super-secret-env"}, + }, + expectedErr: nil, + }), + Entry("with a claim valued header", newInjectorTableInput{ + headers: []options.Header{ + { + Name: "Claim", + Values: []options.HeaderValue{ + { + ClaimSource: &options.ClaimSource{ + Claim: "id_token", + }, + }, + }, + }, + }, + initialHeaders: http.Header{ + "foo": []string{"bar", "baz"}, + }, + session: &sessionsapi.SessionState{ + IDToken: "IDToken-1234", + }, + expectedHeaders: http.Header{ + "foo": []string{"bar", "baz"}, + "Claim": []string{"IDToken-1234"}, + }, + expectedErr: nil, + }), + Entry("with a claim valued header and a nil session", newInjectorTableInput{ + headers: []options.Header{ + { + Name: "Claim", + Values: []options.HeaderValue{ + { + ClaimSource: &options.ClaimSource{ + Claim: "id_token", + }, + }, + }, + }, + }, + initialHeaders: http.Header{ + "foo": []string{"bar", "baz"}, + }, + session: nil, + expectedHeaders: http.Header{ + "foo": []string{"bar", "baz"}, + }, + expectedErr: nil, + }), + Entry("with a prefixed claim valued header", newInjectorTableInput{ + headers: []options.Header{ + { + Name: "Claim", + Values: []options.HeaderValue{ + { + ClaimSource: &options.ClaimSource{ + Claim: "id_token", + Prefix: "Bearer ", + }, + }, + }, + }, + }, + initialHeaders: http.Header{ + "foo": []string{"bar", "baz"}, + }, + session: &sessionsapi.SessionState{ + IDToken: "IDToken-1234", + }, + expectedHeaders: http.Header{ + "foo": []string{"bar", "baz"}, + "Claim": []string{"Bearer IDToken-1234"}, + }, + expectedErr: nil, + }), + Entry("with a prefixed claim valued header missing the claim", newInjectorTableInput{ + headers: []options.Header{ + { + Name: "Claim", + Values: []options.HeaderValue{ + { + ClaimSource: &options.ClaimSource{ + Claim: "idToken", + Prefix: "Bearer ", + }, + }, + }, + }, + }, + initialHeaders: http.Header{ + "foo": []string{"bar", "baz"}, + }, + session: &sessionsapi.SessionState{}, + expectedHeaders: http.Header{ + "foo": []string{"bar", "baz"}, + }, + expectedErr: nil, + }), + Entry("with a basicAuthPassword and claim valued header", newInjectorTableInput{ + headers: []options.Header{ + { + Name: "X-Auth-Request-Authorization", + Values: []options.HeaderValue{ + { + ClaimSource: &options.ClaimSource{ + Claim: "user", + BasicAuthPassword: &options.SecretSource{ + Value: []byte(base64.StdEncoding.EncodeToString([]byte("basic-password"))), + }, + }, + }, + }, + }, + }, + initialHeaders: http.Header{ + "foo": []string{"bar", "baz"}, + }, + session: &sessionsapi.SessionState{ + User: "user-123", + }, + expectedHeaders: http.Header{ + "foo": []string{"bar", "baz"}, + "X-Auth-Request-Authorization": []string{"Basic " + base64.StdEncoding.EncodeToString([]byte("user-123:basic-password"))}, + }, + expectedErr: nil, + }), + Entry("with a basicAuthPassword and claim valued header missing the claim", newInjectorTableInput{ + headers: []options.Header{ + { + Name: "X-Auth-Request-Authorization", + Values: []options.HeaderValue{ + { + ClaimSource: &options.ClaimSource{ + Claim: "user", + BasicAuthPassword: &options.SecretSource{ + Value: []byte(base64.StdEncoding.EncodeToString([]byte("basic-password"))), + }, + }, + }, + }, + }, + }, + initialHeaders: http.Header{ + "foo": []string{"bar", "baz"}, + }, + session: &sessionsapi.SessionState{}, + expectedHeaders: http.Header{ + "foo": []string{"bar", "baz"}, + }, + expectedErr: nil, + }), + Entry("with a header that already exists", newInjectorTableInput{ + headers: []options.Header{ + { + Name: "X-Auth-Request-User", + Values: []options.HeaderValue{ + { + ClaimSource: &options.ClaimSource{ + Claim: "user", + }, + }, + }, + }, + }, + initialHeaders: http.Header{ + "X-Auth-Request-User": []string{"user"}, + }, + session: &sessionsapi.SessionState{ + User: "user-123", + }, + expectedHeaders: http.Header{ + "X-Auth-Request-User": []string{"user", "user-123"}, + }, + expectedErr: nil, + }), + Entry("with a claim and secret valued header value", newInjectorTableInput{ + headers: []options.Header{ + { + Name: "Claim", + Values: []options.HeaderValue{ + { + ClaimSource: &options.ClaimSource{ + Claim: "id_token", + }, + SecretSource: &options.SecretSource{ + FromEnv: "SECRET_ENV", + }, + }, + }, + }, + }, + initialHeaders: http.Header{ + "foo": []string{"bar", "baz"}, + }, + session: &sessionsapi.SessionState{ + IDToken: "IDToken-1234", + }, + expectedHeaders: nil, + expectedErr: errors.New("error building injector for header \"Claim\": header \"Claim\" value has multiple entries: only one entry per value is allowed"), + }), + Entry("with an invalid static valued header", newInjectorTableInput{ + headers: []options.Header{ + { + Name: "Secret", + Values: []options.HeaderValue{ + { + SecretSource: &options.SecretSource{ + FromEnv: "SECRET_ENV", + FromFile: "secret-file", + }, + }, + }, + }, + }, + initialHeaders: http.Header{ + "foo": []string{"bar", "baz"}, + }, + session: &sessionsapi.SessionState{}, + expectedHeaders: nil, + 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"), + }), + Entry("with an invalid basicAuthPassword claim valued header", newInjectorTableInput{ + headers: []options.Header{ + { + Name: "X-Auth-Request-Authorization", + Values: []options.HeaderValue{ + { + ClaimSource: &options.ClaimSource{ + Claim: "user", + BasicAuthPassword: &options.SecretSource{ + Value: []byte(base64.StdEncoding.EncodeToString([]byte("basic-password"))), + FromEnv: "SECRET_ENV", + }, + }, + }, + }, + }, + }, + initialHeaders: http.Header{ + "foo": []string{"bar", "baz"}, + }, + session: &sessionsapi.SessionState{ + User: "user-123", + }, + expectedHeaders: nil, + 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"), + }), + Entry("with a mix of configured headers", newInjectorTableInput{ + headers: []options.Header{ + { + Name: "X-Auth-Request-Authorization", + Values: []options.HeaderValue{ + { + ClaimSource: &options.ClaimSource{ + Claim: "user", + BasicAuthPassword: &options.SecretSource{ + Value: []byte(base64.StdEncoding.EncodeToString([]byte("basic-password"))), + }, + }, + }, + }, + }, + { + Name: "X-Auth-Request-User", + Values: []options.HeaderValue{ + { + ClaimSource: &options.ClaimSource{ + Claim: "user", + }, + }, + }, + }, + { + Name: "X-Auth-Request-Email", + Values: []options.HeaderValue{ + { + ClaimSource: &options.ClaimSource{ + Claim: "email", + }, + }, + }, + }, + { + Name: "X-Auth-Request-Version-Info", + Values: []options.HeaderValue{ + { + SecretSource: &options.SecretSource{ + Value: []byte(base64.StdEncoding.EncodeToString([]byte("major=1"))), + }, + }, + { + SecretSource: &options.SecretSource{ + Value: []byte(base64.StdEncoding.EncodeToString([]byte("minor=2"))), + }, + }, + { + SecretSource: &options.SecretSource{ + Value: []byte(base64.StdEncoding.EncodeToString([]byte("patch=3"))), + }, + }, + }, + }, + }, + initialHeaders: http.Header{ + "foo": []string{"bar", "baz"}, + }, + session: &sessionsapi.SessionState{ + User: "user-123", + Email: "user@example.com", + }, + expectedHeaders: http.Header{ + "foo": []string{"bar", "baz"}, + "X-Auth-Request-Authorization": []string{"Basic " + base64.StdEncoding.EncodeToString([]byte("user-123:basic-password"))}, + "X-Auth-Request-User": []string{"user-123"}, + "X-Auth-Request-Email": []string{"user@example.com"}, + "X-Auth-Request-Version-Info": []string{"major=1", "minor=2", "patch=3"}, + }, + expectedErr: nil, + }), + ) + }) +})