Skip to content

Commit

Permalink
Custom authenticator logic
Browse files Browse the repository at this point in the history
Fixes open-telemetry#2101

Signed-off-by: Juraci Paixão Kröhling <[email protected]>
  • Loading branch information
jpkrohling committed Apr 23, 2021
1 parent 396948f commit 5e4ddd4
Show file tree
Hide file tree
Showing 32 changed files with 771 additions and 441 deletions.
39 changes: 29 additions & 10 deletions config/configauth/README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,36 @@
# Authentication configuration for receivers

This module allows server types, such as gRPC and HTTP, to be configured to perform authentication for requests and/or RPCs. Each server type is responsible for getting the request/RPC metadata and passing down to the authenticator. Currently, only bearer token authentication is supported, although the module is ready to accept new authenticators.
This module allows server types, such as gRPC and HTTP, to be configured to perform authentication for requests and/or RPCs. Each server type is responsible for getting the request/RPC metadata and passing down to the authenticator.

The currently known authenticators:

- [oidc](../../extension/authoidcextension)

Examples:
```yaml
extensions:
oidc:
# see the blog post on securing the otelcol for information
# on how to setup an OIDC server and how to generate the TLS certs
# required for this example
# https://medium.com/opentelemetry/securing-your-opentelemetry-collector-1a4f9fa5bd6f
issuer_url: http://localhost:8080/auth/realms/opentelemetry
audience: account

receivers:
somereceiver:
grpc:
authentication:
attribute: authorization
oidc:
issuer_url: https://auth.example.com/
issuer_ca_path: /etc/pki/tls/cert.pem
client_id: my-oidc-client
username_claim: email
otlp/with_auth:
protocols:
grpc:
endpoint: localhost:4318
tls_settings:
cert_file: /tmp/certs/cert.pem
key_file: /tmp/certs/cert-key.pem
auth:
authenticator: oidc
```
## Creating an authenticator
New authenticators can be added by creating a new extension that also implements the `configauth.Authenticator` extension. Generic authenticators that may be used by a good number of users might be accepted as part of the core distribution, or as part of the contrib distribution. If you have interest in contributing one authenticator, open an issue with your proposal.

For other cases, you'll need to include your custom authenticator as part of your custom OpenTelemetry Collector, perhaps being built using the [OpenTelemetry Collector Builder](https://github.com/open-telemetry/opentelemetry-collector-builder).
44 changes: 20 additions & 24 deletions config/configauth/authenticator.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,53 +17,48 @@ package configauth
import (
"context"
"errors"
"io"

"google.golang.org/grpc"
"google.golang.org/grpc/metadata"

"go.opentelemetry.io/collector/component"
)

var (
errNoOIDCProvided = errors.New("no OIDC information provided")
errMetadataNotFound = errors.New("no request metadata found")
defaultAttribute = "authorization"
)

// Authenticator will authenticate the incoming request/RPC
// Authenticator is an Extension that can be used as an authenticator for the configauth.Authentication option.
// Authenticators are then included as part of OpenTelemetry Collector builds and can be referenced by their
// names from the Authentication configuration. Each Authenticator is free to define its own behavior and configuration options,
// but note that the expectations that come as part of Extensions exist here as well. For instance, multiple instances of the same
// authenticator should be possible to exist under different names.
type Authenticator interface {
io.Closer
component.Extension

// 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.
// Implementations should add the derived subject (user, principal) to a new context built based on the given context under the key configauth.SubjectKey.
// If group/membership information is available, it should also be added, under the key configauth.GroupsKey.
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)
type unaryInterceptorFunc func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler, authenticate authenticateFunc) (interface{}, error)
type streamInterceptorFunc func(srv interface{}, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler, authenticate authenticateFunc) error

// NewAuthenticator creates an authenticator based on the given configuration
func NewAuthenticator(cfg Authentication) (Authenticator, error) {
if cfg.OIDC == nil {
return nil, errNoOIDCProvided
}
// AuthenticateFunc defines the signature for the function responsible for performing the authentication based on the context data.
type AuthenticateFunc func(context.Context, map[string][]string) (context.Context, error)

if len(cfg.Attribute) == 0 {
cfg.Attribute = defaultAttribute
}
// UnaryInterceptorFunc defines the signature for the function intercepting unary gRPC calls.
type UnaryInterceptorFunc func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler, authenticate AuthenticateFunc) (interface{}, error)

return newOIDCAuthenticator(cfg)
}
// StreamInterceptorFunc defines the signature for hte function intercepting streaming gRPC calls.
type StreamInterceptorFunc func(srv interface{}, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler, authenticate AuthenticateFunc) error

func defaultUnaryInterceptor(ctx context.Context, req interface{}, _ *grpc.UnaryServerInfo, handler grpc.UnaryHandler, authenticate authenticateFunc) (interface{}, error) {
// DefaultUnaryInterceptor provides a default implementation of a unary inteceptor, useful for most authenticators.
func DefaultUnaryInterceptor(ctx context.Context, req interface{}, _ *grpc.UnaryServerInfo, handler grpc.UnaryHandler, authenticate AuthenticateFunc) (interface{}, error) {
headers, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, errMetadataNotFound
Expand All @@ -77,7 +72,8 @@ func defaultUnaryInterceptor(ctx context.Context, req interface{}, _ *grpc.Unary
return handler(ctx, req)
}

func defaultStreamInterceptor(srv interface{}, stream grpc.ServerStream, _ *grpc.StreamServerInfo, handler grpc.StreamHandler, authenticate authenticateFunc) error {
// DefaultStreamInterceptor provides a default implementation of a stream interceptor, useful for most authenticators.
func DefaultStreamInterceptor(srv interface{}, stream grpc.ServerStream, _ *grpc.StreamServerInfo, handler grpc.StreamHandler, authenticate AuthenticateFunc) error {
ctx := stream.Context()
headers, ok := metadata.FromIncomingContext(ctx)
if !ok {
Expand Down
35 changes: 6 additions & 29 deletions config/configauth/authenticator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,29 +24,6 @@ import (
"google.golang.org/grpc/metadata"
)

func TestNewAuthenticator(t *testing.T) {
// test
p, err := NewAuthenticator(Authentication{
OIDC: &OIDC{
Audience: "some-audience",
IssuerURL: "http://example.com",
},
})

// verify
assert.NotNil(t, p)
assert.NoError(t, err)
}

func TestMissingOIDC(t *testing.T) {
// test
p, err := NewAuthenticator(Authentication{})

// verify
assert.Nil(t, p)
assert.Equal(t, errNoOIDCProvided, err)
}

func TestDefaultUnaryInterceptorAuthSucceeded(t *testing.T) {
// prepare
handlerCalled := false
Expand All @@ -62,7 +39,7 @@ func TestDefaultUnaryInterceptorAuthSucceeded(t *testing.T) {
ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("authorization", "some-auth-data"))

// test
res, err := defaultUnaryInterceptor(ctx, nil, &grpc.UnaryServerInfo{}, handler, authFunc)
res, err := DefaultUnaryInterceptor(ctx, nil, &grpc.UnaryServerInfo{}, handler, authFunc)

// verify
assert.Nil(t, res)
Expand All @@ -86,7 +63,7 @@ func TestDefaultUnaryInterceptorAuthFailure(t *testing.T) {
ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("authorization", "some-auth-data"))

// test
res, err := defaultUnaryInterceptor(ctx, nil, &grpc.UnaryServerInfo{}, handler, authFunc)
res, err := DefaultUnaryInterceptor(ctx, nil, &grpc.UnaryServerInfo{}, handler, authFunc)

// verify
assert.Nil(t, res)
Expand All @@ -106,7 +83,7 @@ func TestDefaultUnaryInterceptorMissingMetadata(t *testing.T) {
}

// test
res, err := defaultUnaryInterceptor(context.Background(), nil, &grpc.UnaryServerInfo{}, handler, authFunc)
res, err := DefaultUnaryInterceptor(context.Background(), nil, &grpc.UnaryServerInfo{}, handler, authFunc)

// verify
assert.Nil(t, res)
Expand All @@ -131,7 +108,7 @@ func TestDefaultStreamInterceptorAuthSucceeded(t *testing.T) {
}

// test
err := defaultStreamInterceptor(nil, streamServer, &grpc.StreamServerInfo{}, handler, authFunc)
err := DefaultStreamInterceptor(nil, streamServer, &grpc.StreamServerInfo{}, handler, authFunc)

// verify
assert.NoError(t, err)
Expand All @@ -157,7 +134,7 @@ func TestDefaultStreamInterceptorAuthFailure(t *testing.T) {
}

// test
err := defaultStreamInterceptor(nil, streamServer, &grpc.StreamServerInfo{}, handler, authFunc)
err := DefaultStreamInterceptor(nil, streamServer, &grpc.StreamServerInfo{}, handler, authFunc)

// verify
assert.Equal(t, expectedErr, err)
Expand All @@ -179,7 +156,7 @@ func TestDefaultStreamInterceptorMissingMetadata(t *testing.T) {
}

// test
err := defaultStreamInterceptor(nil, streamServer, &grpc.StreamServerInfo{}, handler, authFunc)
err := DefaultStreamInterceptor(nil, streamServer, &grpc.StreamServerInfo{}, handler, authFunc)

// verify
assert.Equal(t, errMetadataNotFound, err)
Expand Down
74 changes: 32 additions & 42 deletions config/configauth/configauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,60 +15,50 @@
package configauth

import (
"context"
"errors"
"fmt"

"google.golang.org/grpc"

"go.opentelemetry.io/collector/component"
"go.opentelemetry.io/collector/config"
)

var (
errAuthenticatorNotFound error = errors.New("authenticator not found")
errAuthenticatorNotProvided error = errors.New("authenticator not provided")
)

// Authentication defines the auth settings for the receiver
type Authentication struct {
// The attribute (header name) to look for auth data. Optional, default value: "authentication".
Attribute string `mapstructure:"attribute"`

// OIDC configures this receiver to use the given OIDC provider as the backend for the authentication mechanism.
// Required.
OIDC *OIDC `mapstructure:"oidc"`
// Authenticator specifies the name of the extension to use in order to authenticate the incoming data point.
AuthenticatorName string `mapstructure:"authenticator"`
}

// OIDC defines the OpenID Connect properties for this processor
type OIDC struct {
// IssuerURL is the base URL for the OIDC provider.
// Required.
IssuerURL string `mapstructure:"issuer_url"`

// Audience of the token, used during the verification.
// For example: "https://accounts.google.com" or "https://login.salesforce.com".
// Required.
Audience string `mapstructure:"audience"`

// The local path for the issuer CA's TLS server cert.
// Optional.
IssuerCAPath string `mapstructure:"issuer_ca_path"`

// The claim to use as the username, in case the token's 'sub' isn't the suitable source.
// Optional.
UsernameClaim string `mapstructure:"username_claim"`

// The claim that holds the subject's group membership information.
// Optional.
GroupsClaim string `mapstructure:"groups_claim"`
}

// ToServerOptions builds a set of server options ready to be used by the gRPC server
func (a *Authentication) ToServerOptions() ([]grpc.ServerOption, error) {
auth, err := NewAuthenticator(*a)
if err != nil {
return nil, err
// ToServerOption builds a set of server options ready to be used by the gRPC server
func (a *Authentication) ToServerOption(ext map[config.NamedEntity]component.Extension) ([]grpc.ServerOption, error) {
if a.AuthenticatorName == "" {
return nil, errAuthenticatorNotProvided
}

// perhaps we should use a timeout here?
// TODO: we need a hook to call auth.Close()
if err := auth.Start(context.Background()); err != nil {
return nil, err
authenticator := selectAuthenticator(ext, a.AuthenticatorName)
if authenticator == nil {
return nil, fmt.Errorf("failed to resolve authenticator %q: %w", a.AuthenticatorName, errAuthenticatorNotFound)
}

return []grpc.ServerOption{
grpc.UnaryInterceptor(auth.UnaryInterceptor),
grpc.StreamInterceptor(auth.StreamInterceptor),
grpc.UnaryInterceptor(authenticator.UnaryInterceptor),
grpc.StreamInterceptor(authenticator.StreamInterceptor),
}, nil
}

func selectAuthenticator(extensions map[config.NamedEntity]component.Extension, requested string) Authenticator {
for name, ext := range extensions {
if auth, ok := ext.(Authenticator); ok {
if name.Name() == requested {
return auth
}
}
}
return nil
}
66 changes: 34 additions & 32 deletions config/configauth/configauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,57 +18,59 @@ import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"go.opentelemetry.io/collector/component"
"go.opentelemetry.io/collector/config"
)

func TestToServerOptions(t *testing.T) {
// prepare
oidcServer, err := newOIDCServer()
require.NoError(t, err)
oidcServer.Start()
defer oidcServer.Close()

config := Authentication{
OIDC: &OIDC{
IssuerURL: oidcServer.URL,
Audience: "unit-test",
GroupsClaim: "memberships",
},
cfg := &Authentication{
AuthenticatorName: "mock",
}
ext := map[config.NamedEntity]component.Extension{
&config.ExtensionSettings{
NameVal: "mock",
TypeVal: "mock",
}: &MockAuthenticator{},
}

// test
opts, err := config.ToServerOptions()
opts, err := cfg.ToServerOption(ext)

// verify
assert.NoError(t, err)
assert.NotNil(t, opts)
assert.Len(t, opts, 2) // we have two interceptors
}

func TestInvalidConfigurationFailsOnToServerOptions(t *testing.T) {

for _, tt := range []struct {
cfg Authentication
func TestToServerOptionFails(t *testing.T) {
testCases := []struct {
desc string
cfg *Authentication
ext map[config.NamedEntity]component.Extension
expected error
}{
{
Authentication{},
desc: "Authenticator not provided",
cfg: &Authentication{},
ext: map[config.NamedEntity]component.Extension{},
expected: errAuthenticatorNotProvided,
},
{
Authentication{
OIDC: &OIDC{
IssuerURL: "http://oidc.acme.invalid",
Audience: "unit-test",
GroupsClaim: "memberships",
},
desc: "Authenticator not found",
cfg: &Authentication{
AuthenticatorName: "does-not-exist",
},
ext: map[config.NamedEntity]component.Extension{},
expected: errAuthenticatorNotFound,
},
} {
// test
opts, err := tt.cfg.ToServerOptions()

// verify
assert.Error(t, err)
assert.Nil(t, opts)
}

for _, tC := range testCases {
t.Run(tC.desc, func(t *testing.T) {
opts, err := tC.cfg.ToServerOption(tC.ext)
assert.ErrorIs(t, err, tC.expected)
assert.Nil(t, opts)
})
}
}
Loading

0 comments on commit 5e4ddd4

Please sign in to comment.