From 430c0026969b88e1adfbee8c317700173198c53d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juraci=20Paix=C3=A3o=20Kr=C3=B6hling?= Date: Thu, 24 Sep 2020 10:05:11 +0200 Subject: [PATCH] Authentication processor 2/4 - Add auth context and interface (#1808) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Authentication processor 1/4 - Add configauth Signed-off-by: Juraci Paixão Kröhling * Authentication processor 2/4 - Add auth context and interface Signed-off-by: Juraci Paixão Kröhling --- internal/auth/authenticator.go | 95 ++++++++++++++ internal/auth/authenticator_test.go | 197 ++++++++++++++++++++++++++++ internal/auth/context.go | 37 ++++++ internal/auth/context_test.go | 94 +++++++++++++ 4 files changed, 423 insertions(+) create mode 100644 internal/auth/authenticator.go create mode 100644 internal/auth/authenticator_test.go create mode 100644 internal/auth/context.go create mode 100644 internal/auth/context_test.go diff --git a/internal/auth/authenticator.go b/internal/auth/authenticator.go new file mode 100644 index 0000000000000..2657527c2669e --- /dev/null +++ b/internal/auth/authenticator.go @@ -0,0 +1,95 @@ +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +import ( + "context" + "errors" + "io" + + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" + + "go.opentelemetry.io/collector/config/configauth" +) + +var ( + errNoOIDCProvided = errors.New("no OIDC information provided") + errMetadataNotFound = errors.New("no request metadata found") + errNotImplemented = errors.New("not implemented") + defaultAttribute = "authorization" +) + +// Authenticator will authenticate the incoming request/RPC +type Authenticator interface { + io.Closer + + // Authenticate checks whether the given context contains valid auth data. Successfully authenticated calls will always return a nil error and a context with the auth data. + Authenticate(context.Context, map[string][]string) (context.Context, error) + + // Start will + Start(context.Context) error + + // UnaryInterceptor is a helper method to provide a gRPC-compatible UnaryInterceptor, typically calling the authenticator's Authenticate method. + UnaryInterceptor(context.Context, interface{}, *grpc.UnaryServerInfo, grpc.UnaryHandler) (interface{}, error) + + // StreamInterceptor is a helper method to provide a gRPC-compatible StreamInterceptor, typically calling the authenticator's Authenticate method. + StreamInterceptor(interface{}, grpc.ServerStream, *grpc.StreamServerInfo, grpc.StreamHandler) error +} + +type authenticateFunc func(context.Context, map[string][]string) (context.Context, error) + +// New creates an authenticator based on the given configuration +func New(cfg configauth.Authentication) (Authenticator, error) { + if cfg.OIDC == nil { + return nil, errNoOIDCProvided + } + + if len(cfg.Attribute) == 0 { + cfg.Attribute = defaultAttribute + } + + return nil, errNotImplemented +} + +func defaultUnaryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler, authenticate authenticateFunc) (interface{}, error) { + headers, ok := metadata.FromIncomingContext(ctx) + if !ok { + return nil, errMetadataNotFound + } + + ctx, err := authenticate(ctx, headers) + if err != nil { + return nil, err + } + + return handler(ctx, req) +} + +func defaultStreamInterceptor(srv interface{}, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler, authenticate authenticateFunc) error { + ctx := stream.Context() + headers, ok := metadata.FromIncomingContext(ctx) + if !ok { + return errMetadataNotFound + } + + // TODO: how to replace the context from the stream? + _, err := authenticate(ctx, headers) + if err != nil { + return err + } + + return handler(srv, stream) +} diff --git a/internal/auth/authenticator_test.go b/internal/auth/authenticator_test.go new file mode 100644 index 0000000000000..3476f3a64ce38 --- /dev/null +++ b/internal/auth/authenticator_test.go @@ -0,0 +1,197 @@ +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" + + "go.opentelemetry.io/collector/config/configauth" +) + +func TestNew(t *testing.T) { + // test + p, err := New(configauth.Authentication{ + OIDC: &configauth.OIDC{ + Audience: "some-audience", + IssuerURL: "http://example.com", + }, + }) + + // verify + assert.Nil(t, p) + assert.Equal(t, errNotImplemented, err) +} + +func TestMissingOIDC(t *testing.T) { + // test + p, err := New(configauth.Authentication{}) + + // verify + assert.Nil(t, p) + assert.Equal(t, errNoOIDCProvided, err) +} + +func TestDefaultUnaryInterceptorAuthSucceeded(t *testing.T) { + // prepare + handlerCalled := false + authCalled := false + authFunc := func(context.Context, map[string][]string) (context.Context, error) { + authCalled = true + return context.Background(), nil + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + handlerCalled = true + return nil, nil + } + ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("authorization", "some-auth-data")) + + // test + res, err := defaultUnaryInterceptor(ctx, nil, &grpc.UnaryServerInfo{}, handler, authFunc) + + // verify + assert.Nil(t, res) + assert.NoError(t, err) + assert.True(t, authCalled) + assert.True(t, handlerCalled) +} + +func TestDefaultUnaryInterceptorAuthFailure(t *testing.T) { + // prepare + authCalled := false + expectedErr := fmt.Errorf("not authenticated") + authFunc := func(context.Context, map[string][]string) (context.Context, error) { + authCalled = true + return context.Background(), expectedErr + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + assert.FailNow(t, "the handler should not have been called on auth failure!") + return nil, nil + } + ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("authorization", "some-auth-data")) + + // test + res, err := defaultUnaryInterceptor(ctx, nil, &grpc.UnaryServerInfo{}, handler, authFunc) + + // verify + assert.Nil(t, res) + assert.Equal(t, expectedErr, err) + assert.True(t, authCalled) +} + +func TestDefaultUnaryInterceptorMissingMetadata(t *testing.T) { + // prepare + authFunc := func(context.Context, map[string][]string) (context.Context, error) { + assert.FailNow(t, "the auth func should not have been called!") + return context.Background(), nil + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + assert.FailNow(t, "the handler should not have been called!") + return nil, nil + } + + // test + res, err := defaultUnaryInterceptor(context.Background(), nil, &grpc.UnaryServerInfo{}, handler, authFunc) + + // verify + assert.Nil(t, res) + assert.Equal(t, errMetadataNotFound, err) +} + +func TestDefaultStreamInterceptorAuthSucceeded(t *testing.T) { + // prepare + handlerCalled := false + authCalled := false + authFunc := func(context.Context, map[string][]string) (context.Context, error) { + authCalled = true + return context.Background(), nil + } + handler := func(srv interface{}, stream grpc.ServerStream) error { + handlerCalled = true + return nil + } + ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("authorization", "some-auth-data")) + streamServer := &mockServerStream{ + ctx: ctx, + } + + // test + err := defaultStreamInterceptor(nil, streamServer, &grpc.StreamServerInfo{}, handler, authFunc) + + // verify + assert.NoError(t, err) + assert.True(t, authCalled) + assert.True(t, handlerCalled) +} + +func TestDefaultStreamInterceptorAuthFailure(t *testing.T) { + // prepare + authCalled := false + expectedErr := fmt.Errorf("not authenticated") + authFunc := func(context.Context, map[string][]string) (context.Context, error) { + authCalled = true + return context.Background(), expectedErr + } + handler := func(srv interface{}, stream grpc.ServerStream) error { + assert.FailNow(t, "the handler should not have been called on auth failure!") + return nil + } + ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("authorization", "some-auth-data")) + streamServer := &mockServerStream{ + ctx: ctx, + } + + // test + err := defaultStreamInterceptor(nil, streamServer, &grpc.StreamServerInfo{}, handler, authFunc) + + // verify + assert.Equal(t, expectedErr, err) + assert.True(t, authCalled) +} + +func TestDefaultStreamInterceptorMissingMetadata(t *testing.T) { + // prepare + authFunc := func(context.Context, map[string][]string) (context.Context, error) { + assert.FailNow(t, "the auth func should not have been called!") + return context.Background(), nil + } + handler := func(srv interface{}, stream grpc.ServerStream) error { + assert.FailNow(t, "the handler should not have been called!") + return nil + } + streamServer := &mockServerStream{ + ctx: context.Background(), + } + + // test + err := defaultStreamInterceptor(nil, streamServer, &grpc.StreamServerInfo{}, handler, authFunc) + + // verify + assert.Equal(t, errMetadataNotFound, err) +} + +type mockServerStream struct { + grpc.ServerStream + ctx context.Context +} + +func (m *mockServerStream) Context() context.Context { + return m.ctx +} diff --git a/internal/auth/context.go b/internal/auth/context.go new file mode 100644 index 0000000000000..2637f5d59769b --- /dev/null +++ b/internal/auth/context.go @@ -0,0 +1,37 @@ +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +import "context" + +var ( + subjectKey = subjectType{} + groupsKey = groupsType{} +) + +type subjectType struct{} +type groupsType struct{} + +// SubjectFromContext returns a list of groups the subject in the context belongs to +func SubjectFromContext(ctx context.Context) (string, bool) { + value, ok := ctx.Value(subjectKey).(string) + return value, ok +} + +// GroupsFromContext returns a list of groups the subject in the context belongs to +func GroupsFromContext(ctx context.Context) ([]string, bool) { + value, ok := ctx.Value(groupsKey).([]string) + return value, ok +} diff --git a/internal/auth/context_test.go b/internal/auth/context_test.go new file mode 100644 index 0000000000000..a22ae54d6e85f --- /dev/null +++ b/internal/auth/context_test.go @@ -0,0 +1,94 @@ +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSubjectFromContext(t *testing.T) { + // prepare + ctx := context.WithValue(context.Background(), subjectKey, "my-subject") + + // test + sub, ok := SubjectFromContext(ctx) + + // verify + assert.Equal(t, "my-subject", sub) + assert.True(t, ok) +} + +func TestSubjectFromContextNotPresent(t *testing.T) { + // prepare + ctx := context.Background() + + // test + sub, ok := SubjectFromContext(ctx) + + // verify + assert.False(t, ok) + assert.Empty(t, sub) +} + +func TestSubjectFromContextWrongType(t *testing.T) { + // prepare + ctx := context.WithValue(context.Background(), subjectKey, 123) + + // test + sub, ok := SubjectFromContext(ctx) + + // verify + assert.False(t, ok) + assert.Empty(t, sub) +} + +func TestGroupsFromContext(t *testing.T) { + // prepare + ctx := context.WithValue(context.Background(), groupsKey, []string{"my-groups"}) + + // test + groups, ok := GroupsFromContext(ctx) + + // verify + assert.Equal(t, []string{"my-groups"}, groups) + assert.True(t, ok) +} + +func TestGroupsFromContextNotPresent(t *testing.T) { + // prepare + ctx := context.Background() + + // test + sub, ok := GroupsFromContext(ctx) + + // verify + assert.False(t, ok) + assert.Empty(t, sub) +} + +func TestGroupsFromContextWrongType(t *testing.T) { + // prepare + ctx := context.WithValue(context.Background(), subjectKey, 123) + + // test + sub, ok := GroupsFromContext(ctx) + + // verify + assert.False(t, ok) + assert.Empty(t, sub) +}