Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Establish the auth context for authenticators to add data to #3552

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions auth/context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// 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 (
rawKey = rawType{}
subjectKey = subjectType{}
groupsKey = groupsType{}
)

type rawType struct{}
type subjectType struct{}
Comment on lines +19 to +26
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need this in a generic auth/context.go at all? These appear to be specific context data for example by oidc auth extension. Why not declare there on oidc implementation?
I think we should aim for every auth extension to declare its own context data.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are not specific to the OIDC auth extension. Those three pieces should be common to most authenticators, as they are common parts of any authorization system. For example, we might have a basic auth authenticator, a la Apache httpd: in such case, we would have a .htpasswd that would serve as the user data source (subject), and an optional .htgroups, linking groups with users like Unix' /etc/group. The raw field would be the base64 encoded username:password received by the HTTP server from the client.

I think we should aim for every auth extension to declare its own context data.

I think every extension (not only auth) should be allowed to enhance the context data as they wish so that other components can make use of that information, but we should have a minimal set of options that are common to all authenticators, and this auth/context represents that in my opinion.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I still do not understand why this needs to be in the auth package and not in the specific extension's package which needs this. We are not expecting that we can mix auth extensions in any way, right? So why not move these declarations to the extension which plans to inject these values in the context?
I may be missing something but I think different authenticator extensions will need different values. Some authenticators will only need raw auth token others may need a subject or whatever it is called.
I don't understand what do we gain by making these concepts part of the public API of core and not just delegate it to individual auth extensions.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are not expecting that we can mix auth extensions in any way, right?

We certainly expect auth extensions to provide data that will be consumed by other components in the pipeline. For instance, the routing processor can make decisions based on the principal or group membership (tenant "acme" should go to this Jaeger, tenant "ecorp" should go to that Zipkin, ...).

All auth extensions are expected to have at least the notion of principal (like a username, or a service account name, or ...) Raw can certainly be removed from the generic package, though. I would also keep the group information, as it's also a common aspect of auth no matter the backend used.

I don't understand what do we gain by making these concepts part of the public API of core and not just delegate it to individual auth extensions.

Other components, like the routing processor I mentioned earlier, might provide tighter integration with the auth features, without requiring specific authenticators to exist.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jpkrohling can you please show how a routing processor could use the auth data? A sample code somewhere would be great to see and would help review this PR.

Copy link
Member Author

@jpkrohling jpkrohling Jul 30, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It cannot right now because of this missing piece, but this is how a configuration would look like for the existing routingprocessor:

receivers:
  nop:

processors:
  routing:
    default_exporters:
    - otlp
    from_auth: membership
    table:
    - value: acme
      exporters: 
      - jaeger/acme
      - otlp/acme
    - value: globex
      exporters:
      - otlp/globex

exporters:
  otlp:
  otlp/acme:
  otlp/globex:
  jaeger/acme:
    endpoint: localhost:14250

service:
  pipelines:
    traces:
      receivers:
      - nop
      processors:
      - routing
      exporters:
      - jaeger/acme
      - otlp/acme
      - otlp/globex

In this example, the authentication for a hypothetical user "service-a", belonging to the tenant "acme" would cause the authenticator to place "service-a" as the subject/principal and "acme" in the group membership.

The routingprocessor would probably need to group the data by auth/membership, but minimal changes would be required to the current code, probably isolated to this function here: https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/36a7da6a4c29ddf81d118ec159e0a7a1682c6f21/processor/routingprocessor/routing.go#L133-L134 . Instead of extracting the route destination from the context, it would extract from the pdata.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at my notes, I think we did discuss something else though:

  • the authenticators would place auth data into the context
  • something at the junction point between receivers and processors would place this into pdata
  • processors/exporters should rely on the data from pdata instead of the context

Other than that, the general idea remains.

type groupsType struct{}
Comment on lines +25 to +27
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to have all three of them in the same time? Can we actually have one key with an enum type?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe have a "AuthContext" struct which embeds different auth types?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a library that provides this?

Copy link
Member Author

@jpkrohling jpkrohling Jul 12, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to have all three of them in the same time? Can we actually have one key with an enum type?

Yes, and quite common actually.

Maybe have a "AuthContext" struct which embeds different auth types?

Those are not auth types. The raw auth data might be a JWT, which contains the principal and group memberships. The authenticator (OIDC, for instance) will then place all the three components in the context. Another authenticator might get the subject and groups from the auth server.

I can split this if we need more time, but I think the third commit brings only a minimal API, generic enough to be extended with custom types in the future. The idea about AuthContext has been brought by @tigrannajaryan as well, though:

6b6e713#diff-208f8af800c6aa88673523930b718a5d1a5b3f2a2d2f6edbdcf97171ce3d15ca

Is there a library that provides this?

I'm not aware of any. This is really only about adding auth to a context.Context, but I'm open to using a library if it brings value.


// NewContextFromRaw creates a new context derived from the given context,
// adding the raw authentication string to the result.
func NewContextFromRaw(ctx context.Context, raw string) context.Context {
return context.WithValue(ctx, rawKey, raw)
}

// NewContextFromSubject creates a new context derived from the given context,
// adding the subject to the result.
func NewContextFromSubject(ctx context.Context, subject string) context.Context {
return context.WithValue(ctx, subjectKey, subject)
}

// NewContextFromMemberships creates a new context derived from the given context,
// adding the memberships to the result.
func NewContextFromMemberships(ctx context.Context, groups []string) context.Context {
return context.WithValue(ctx, groupsKey, groups)
}

// RawFromContext returns the raw authentication string used to perform the authentication.
// It's typically the string value for a single HTTP header, such as the "Authentication" header.
// Example: "Basic ZGV2ZWxvcGVyOmN1cmlvdXM=".
func RawFromContext(ctx context.Context) (string, bool) {
value, ok := ctx.Value(rawKey).(string)
return value, ok
}

// SubjectFromContext returns the subject that was extracted from the raw authentication string.
func SubjectFromContext(ctx context.Context) (string, bool) {
value, ok := ctx.Value(subjectKey).(string)
return value, ok
}

// GroupsFromContext returns the list of groups the subject belongs.
func GroupsFromContext(ctx context.Context) ([]string, bool) {
value, ok := ctx.Value(groupsKey).([]string)
return value, ok
}
4 changes: 2 additions & 2 deletions config/configauth/mock_serverauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ type MockAuthenticator struct {
}

// Authenticate executes the mock's AuthenticateFunc, if provided, or just returns the given context unchanged.
func (m *MockAuthenticator) Authenticate(ctx context.Context, headers map[string][]string) error {
func (m *MockAuthenticator) Authenticate(ctx context.Context, headers map[string][]string) (context.Context, error) {
if m.AuthenticateFunc == nil {
return nil
return context.Background(), nil
}
return m.AuthenticateFunc(ctx, headers)
}
Expand Down
10 changes: 6 additions & 4 deletions config/configauth/mock_serverauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,18 @@ func TestAuthenticateFunc(t *testing.T) {
// prepare
m := &MockAuthenticator{}
called := false
m.AuthenticateFunc = func(c context.Context, m map[string][]string) error {
m.AuthenticateFunc = func(c context.Context, m map[string][]string) (context.Context, error) {
called = true
return nil
return context.Background(), nil
}

// test
err := m.Authenticate(context.Background(), nil)
ctx, err := m.Authenticate(context.Background(), nil)

// verify
assert.NoError(t, err)
assert.True(t, called)
assert.NotNil(t, ctx)
}

func TestNilOperations(t *testing.T) {
Expand All @@ -46,8 +47,9 @@ func TestNilOperations(t *testing.T) {
origCtx := context.Background()

{
err := m.Authenticate(origCtx, nil)
ctx, err := m.Authenticate(origCtx, nil)
assert.NoError(t, err)
assert.NotNil(t, ctx)
}

{
Expand Down
15 changes: 11 additions & 4 deletions config/configauth/serverauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,11 @@ type ServerAuthenticator interface {
// When the authentication fails, an error must be returned and the caller must not retry. This function is typically called from interceptors,
// on behalf of receivers, but receivers can still call this directly if the usage of interceptors isn't suitable.
// The deadline and cancellation given to this function must be respected, but note that authentication data has to be part of the map, not context.
Authenticate(ctx context.Context, headers map[string][]string) error
// The resulting context should contain the authentication data, such as the principal/username, group membership (if available), and the raw
// authentication data (if possible). This will allow other components in the pipeline to make decisions based on that data, such as routing based
// on tenancy as determined by the group membership, or passing through the authentication data to the next collector/backend.
// The context keys to be used are not defined yet.
Authenticate(ctx context.Context, headers map[string][]string) (context.Context, error)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add an explanation in the comments about what the returned context is expected to be?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left that part out so that we can define it when we have a first implementation making use of it, but I added the best information we have so far.

Should I define the context keys as part of this PR? It would be basically this: https://github.com/open-telemetry/opentelemetry-collector/pull/3239/files#diff-1c8371e19129e2092ab7abfce04e6558218579f1be29d90adb958d230475584f

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In any case, I updated the PR to include a few words about the resulting context.

Copy link
Member Author

@jpkrohling jpkrohling Jul 8, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry about the noise, but I thought it would be better to include the auth keys as part of the API for v1, so, I included it in this PR.


// GRPCUnaryServerInterceptor is a helper method to provide a gRPC-compatible UnaryServerInterceptor, typically calling the authenticator's Authenticate method.
// While the context is the typical source of authentication data, the interceptor is free to determine where the auth data should come from. For instance, some
Expand All @@ -59,7 +63,7 @@ type ServerAuthenticator interface {

// AuthenticateFunc defines the signature for the function responsible for performing the authentication based on the given headers map.
// See ServerAuthenticator.Authenticate.
type AuthenticateFunc func(ctx context.Context, headers map[string][]string) error
type AuthenticateFunc func(ctx context.Context, headers map[string][]string) (context.Context, error)

// GRPCUnaryInterceptorFunc defines the signature for the function intercepting unary gRPC calls, useful for authenticators to use as
// types for internal structs, making it easier to mock them in tests.
Expand All @@ -79,7 +83,8 @@ func DefaultGRPCUnaryServerInterceptor(ctx context.Context, req interface{}, _ *
return nil, errMetadataNotFound
}

if err := authenticate(ctx, headers); err != nil {
ctx, err := authenticate(ctx, headers)
if err != nil {
return nil, err
}

Expand All @@ -95,7 +100,9 @@ func DefaultGRPCStreamServerInterceptor(srv interface{}, stream grpc.ServerStrea
return errMetadataNotFound
}

if err := authenticate(ctx, headers); err != nil {
// TODO: propagate the context down the stream
_, err := authenticate(ctx, headers)
if err != nil {
return err
}

Expand Down
24 changes: 12 additions & 12 deletions config/configauth/serverauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ func TestDefaultUnaryInterceptorAuthSucceeded(t *testing.T) {
// prepare
handlerCalled := false
authCalled := false
authFunc := func(context.Context, map[string][]string) error {
authFunc := func(context.Context, map[string][]string) (context.Context, error) {
authCalled = true
return nil
return context.Background(), nil
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
handlerCalled = true
Expand All @@ -52,9 +52,9 @@ func TestDefaultUnaryInterceptorAuthFailure(t *testing.T) {
// prepare
authCalled := false
expectedErr := fmt.Errorf("not authenticated")
authFunc := func(context.Context, map[string][]string) error {
authFunc := func(context.Context, map[string][]string) (context.Context, error) {
authCalled = true
return expectedErr
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!")
Expand All @@ -73,9 +73,9 @@ func TestDefaultUnaryInterceptorAuthFailure(t *testing.T) {

func TestDefaultUnaryInterceptorMissingMetadata(t *testing.T) {
// prepare
authFunc := func(context.Context, map[string][]string) error {
authFunc := func(context.Context, map[string][]string) (context.Context, error) {
assert.FailNow(t, "the auth func should not have been called!")
return nil
return context.Background(), nil
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
assert.FailNow(t, "the handler should not have been called!")
Expand All @@ -94,9 +94,9 @@ func TestDefaultStreamInterceptorAuthSucceeded(t *testing.T) {
// prepare
handlerCalled := false
authCalled := false
authFunc := func(context.Context, map[string][]string) error {
authFunc := func(context.Context, map[string][]string) (context.Context, error) {
authCalled = true
return nil
return context.Background(), nil
}
handler := func(srv interface{}, stream grpc.ServerStream) error {
handlerCalled = true
Expand All @@ -120,9 +120,9 @@ func TestDefaultStreamInterceptorAuthFailure(t *testing.T) {
// prepare
authCalled := false
expectedErr := fmt.Errorf("not authenticated")
authFunc := func(context.Context, map[string][]string) error {
authFunc := func(context.Context, map[string][]string) (context.Context, error) {
authCalled = true
return expectedErr
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!")
Expand All @@ -143,9 +143,9 @@ func TestDefaultStreamInterceptorAuthFailure(t *testing.T) {

func TestDefaultStreamInterceptorMissingMetadata(t *testing.T) {
// prepare
authFunc := func(context.Context, map[string][]string) error {
authFunc := func(context.Context, map[string][]string) (context.Context, error) {
assert.FailNow(t, "the auth func should not have been called!")
return nil
return context.Background(), nil
}
handler := func(srv interface{}, stream grpc.ServerStream) error {
assert.FailNow(t, "the handler should not have been called!")
Expand Down
31 changes: 20 additions & 11 deletions extension/oidcauthextension/extension.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import (
"go.uber.org/zap"
"google.golang.org/grpc"

"go.opentelemetry.io/collector/auth"
"go.opentelemetry.io/collector/component"
"go.opentelemetry.io/collector/config/configauth"
)
Expand Down Expand Up @@ -100,21 +101,23 @@ func (e *oidcExtension) Shutdown(context.Context) error {
}

// 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.
func (e *oidcExtension) Authenticate(ctx context.Context, headers map[string][]string) error {
func (e *oidcExtension) Authenticate(ctx context.Context, headers map[string][]string) (context.Context, error) {
authHeaders := headers[e.cfg.Attribute]
if len(authHeaders) == 0 {
return errNotAuthenticated
return ctx, errNotAuthenticated
}

raw := authHeaders[0]

// we only use the first header, if multiple values exist
parts := strings.Split(authHeaders[0], " ")
parts := strings.Split(raw, " ")
if len(parts) != 2 {
return errInvalidAuthenticationHeaderFormat
return ctx, errInvalidAuthenticationHeaderFormat
}

idToken, err := e.verifier.Verify(ctx, parts[1])
if err != nil {
return fmt.Errorf("failed to verify token: %w", err)
return ctx, fmt.Errorf("failed to verify token: %w", err)
}

claims := map[string]interface{}{}
Expand All @@ -125,20 +128,26 @@ func (e *oidcExtension) Authenticate(ctx context.Context, headers map[string][]s
// to read the claims. It could fail if we were using a custom struct. Instead of
// swalling the error, it's better to make this future-proof, in case the underlying
// code changes
return errFailedToObtainClaimsFromToken
return ctx, errFailedToObtainClaimsFromToken
}

_, err = getSubjectFromClaims(claims, e.cfg.UsernameClaim, idToken.Subject)
// we could have set this right after obtaining the raw auth string, but we should only change the
// context if the auth was successful
ctx = auth.NewContextFromRaw(ctx, raw)

sub, err := getSubjectFromClaims(claims, e.cfg.UsernameClaim, idToken.Subject)
if err != nil {
return fmt.Errorf("failed to get subject from claims in the token: %w", err)
return ctx, fmt.Errorf("failed to get subject from claims in the token: %w", err)
}
ctx = auth.NewContextFromSubject(ctx, sub)

_, err = getGroupsFromClaims(claims, e.cfg.GroupsClaim)
groups, err := getGroupsFromClaims(claims, e.cfg.GroupsClaim)
if err != nil {
return fmt.Errorf("failed to get groups from claims in the token: %w", err)
return ctx, fmt.Errorf("failed to get groups from claims in the token: %w", err)
}
ctx = auth.NewContextFromMemberships(ctx, groups)

return nil
return ctx, nil
}

// GRPCUnaryServerInterceptor is a helper method to provide a gRPC-compatible UnaryInterceptor, typically calling the authenticator's Authenticate method.
Expand Down
15 changes: 10 additions & 5 deletions extension/oidcauthextension/extension_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,11 @@ func TestOIDCAuthenticationSucceeded(t *testing.T) {
require.NoError(t, err)

// test
err = p.Authenticate(context.Background(), map[string][]string{"authorization": {fmt.Sprintf("Bearer %s", token)}})
ctx, err := p.Authenticate(context.Background(), map[string][]string{"authorization": {fmt.Sprintf("Bearer %s", token)}})

// verify
assert.NoError(t, err)
assert.NotNil(t, ctx)

// TODO(jpkroehling): assert that the authentication routine set the subject/membership to the resource
}
Expand Down Expand Up @@ -209,10 +210,11 @@ func TestOIDCInvalidAuthHeader(t *testing.T) {
require.NoError(t, err)

// test
err = p.Authenticate(context.Background(), map[string][]string{"authorization": {"some-value"}})
ctx, err := p.Authenticate(context.Background(), map[string][]string{"authorization": {"some-value"}})

// verify
assert.Equal(t, errInvalidAuthenticationHeaderFormat, err)
assert.NotNil(t, ctx)
}

func TestOIDCNotAuthenticated(t *testing.T) {
Expand All @@ -224,10 +226,11 @@ func TestOIDCNotAuthenticated(t *testing.T) {
require.NoError(t, err)

// test
err = p.Authenticate(context.Background(), make(map[string][]string))
ctx, err := p.Authenticate(context.Background(), make(map[string][]string))

// verify
assert.Equal(t, errNotAuthenticated, err)
assert.NotNil(t, ctx)
}

func TestProviderNotReacheable(t *testing.T) {
Expand Down Expand Up @@ -262,10 +265,11 @@ func TestFailedToVerifyToken(t *testing.T) {
require.NoError(t, err)

// test
err = p.Authenticate(context.Background(), map[string][]string{"authorization": {"Bearer some-token"}})
ctx, err := p.Authenticate(context.Background(), map[string][]string{"authorization": {"Bearer some-token"}})

// verify
assert.Error(t, err)
assert.NotNil(t, ctx)
}

func TestFailedToGetGroupsClaimFromToken(t *testing.T) {
Expand Down Expand Up @@ -325,10 +329,11 @@ func TestFailedToGetGroupsClaimFromToken(t *testing.T) {
require.NoError(t, err)

// test
err = p.Authenticate(context.Background(), map[string][]string{"authorization": {fmt.Sprintf("Bearer %s", token)}})
ctx, err := p.Authenticate(context.Background(), map[string][]string{"authorization": {fmt.Sprintf("Bearer %s", token)}})

// verify
assert.ErrorIs(t, err, tt.expectedError)
assert.NotNil(t, ctx)
})
}
}
Expand Down