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

feat(server/auth): validate flipt_client_token cookie in middleware #1139

Merged
merged 13 commits into from
Dec 12, 2022
Merged
Show file tree
Hide file tree
Changes from 2 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
104 changes: 84 additions & 20 deletions internal/server/auth/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ package auth

import (
"context"
"fmt"
"net/http"
"strings"
"time"

"go.flipt.io/flipt/internal/containers"
authrpc "go.flipt.io/flipt/rpc/flipt/auth"
"go.uber.org/zap"
"google.golang.org/grpc"
Expand All @@ -13,7 +15,11 @@ import (
"google.golang.org/grpc/status"
)

const authenticationHeaderKey = "authorization"
const (
authenticationHeaderKey = "authorization"
cookieHeaderKey = "cookie"
cookieKey = "flipt_client_token"
)

var errUnauthenticated = status.Error(codes.Unauthenticated, "request was not authenticated")

Expand All @@ -37,27 +43,57 @@ func GetAuthenticationFrom(ctx context.Context) *authrpc.Authentication {
return auth.(*authrpc.Authentication)
}

// InterceptorOptions configure the UnaryInterceptor
type InterceptorOptions struct {
skippedServers []any
}

func (o InterceptorOptions) skipped(server any) bool {
for _, s := range o.skippedServers {
if s == server {
return true
}
}

return false
}

// WithServerSkipsAuthentication can be used to configure an auth unary interceptor
// which skips authentication when the provided server instance matches the intercepted
// calls parent server instance.
// This allows the caller to registers servers which explicitly skip authentication (e.g. OIDC).
func WithServerSkipsAuthentication(server any) containers.Option[InterceptorOptions] {
Copy link
Collaborator

Choose a reason for hiding this comment

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

is there anyway to identify a server by name instead of doing pointer comparison (when checking for if it's in the o.skippedServers[])?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think there might be. We could do a comparison on the string of the full method: https://pkg.go.dev/google.golang.org/grpc#UnaryServerInfo

However, I felt this was relatively more concrete. Since it is direct pointer comparison with the instance we want to skip auth on. We compare with the method name and then someone renames a package or type, and it moves under us.
While this does use any you still have to pass it something and if that something gets renamed it won't compile until you correct it.

return func(o *InterceptorOptions) {
o.skippedServers = append(o.skippedServers, server)
}
}

// UnaryInterceptor is a grpc.UnaryServerInterceptor which extracts a clientToken found
// within the authorization field on the incoming requests metadata.
// The fields value is expected to be in the form "Bearer <clientToken>".
func UnaryInterceptor(logger *zap.Logger, authenticator Authenticator) grpc.UnaryServerInterceptor {
func UnaryInterceptor(logger *zap.Logger, authenticator Authenticator, o ...containers.Option[InterceptorOptions]) grpc.UnaryServerInterceptor {
var opts InterceptorOptions
containers.ApplyAll(&opts, o...)

return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
// skip auth for any preconfigured servers
if opts.skipped(info.Server) {
logger.Debug("skipping authentication for server", zap.String("method", info.FullMethod))
return handler(ctx, req)
}

md, ok := metadata.FromIncomingContext(ctx)
if !ok {
logger.Error("unauthenticated", zap.String("reason", "metadata not found on context"))
return ctx, errUnauthenticated
}

authenticationHeader := md.Get(authenticationHeaderKey)
if len(authenticationHeader) < 1 {
logger.Error("unauthenticated", zap.String("reason", "no authorization provided"))
return ctx, errUnauthenticated
}
clientToken, err := clientTokenFromMetadata(md)
if err != nil {
logger.Error("unauthenticated",
zap.String("reason", "no authorization provided"),
zap.Error(err))

clientToken := strings.TrimPrefix(authenticationHeader[0], "Bearer ")
// ensure token was prefixed with "Bearer "
if authenticationHeader[0] == clientToken {
logger.Error("unauthenticated", zap.String("reason", "authorization malformed"))
return ctx, errUnauthenticated
}

Expand All @@ -69,14 +105,42 @@ func UnaryInterceptor(logger *zap.Logger, authenticator Authenticator) grpc.Unar
return ctx, errUnauthenticated
}

if auth.ExpiresAt != nil && auth.ExpiresAt.AsTime().Before(time.Now()) {
GeorgeMac marked this conversation as resolved.
Show resolved Hide resolved
logger.Error("unauthenticated",
zap.String("reason", "authorization expired"),
zap.String("authentication_id", auth.Id),
)
return ctx, errUnauthenticated
}

return handler(context.WithValue(ctx, authenticationContextKey{}, auth), req)
}
}

func clientTokenFromMetadata(md metadata.MD) (string, error) {
if authenticationHeader := md.Get(authenticationHeaderKey); len(authenticationHeader) > 0 {
return clientTokenFromAuthorization(authenticationHeader[0])
}

if cookieHeader := md.Get(cookieHeaderKey); len(cookieHeader) > 0 {
return clientTokenFromCookie(cookieHeader[0])
}

return "", errUnauthenticated
}

func clientTokenFromAuthorization(auth string) (string, error) {
// ensure token was prefixed with "Bearer "
if clientToken := strings.TrimPrefix(auth, "Bearer "); auth != clientToken {
return clientToken, nil
}

return "", errUnauthenticated
}

func clientTokenFromCookie(v string) (string, error) {
// sadly net/http does not expose cookie parsing
// outside of http.Request.
// so instead we fabricate a request around the cookie
// in order to extract it appropriately.
cookie, err := (&http.Request{
Header: http.Header{"Cookie": []string{v}},
}).Cookie(cookieKey)
if err != nil {
return "", fmt.Errorf("parsing cookie %q: %w", err.Error(), errUnauthenticated)
}

return cookie.Value, nil
}
52 changes: 30 additions & 22 deletions internal/server/auth/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,57 +3,58 @@ package auth
import (
"context"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.flipt.io/flipt/internal/storage/auth"
"go.flipt.io/flipt/internal/containers"
storageauth "go.flipt.io/flipt/internal/storage/auth"
"go.flipt.io/flipt/internal/storage/auth/memory"
authrpc "go.flipt.io/flipt/rpc/flipt/auth"
"go.uber.org/zap/zaptest"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
"google.golang.org/protobuf/types/known/timestamppb"
)

// fakeserver is used to test skipping auth
var fakeserver struct{}

func TestUnaryInterceptor(t *testing.T) {
authenticator := memory.NewStore()

// valid auth
clientToken, storedAuth, err := authenticator.CreateAuthentication(
context.TODO(),
&auth.CreateAuthenticationRequest{Method: authrpc.Method_METHOD_TOKEN},
)
require.NoError(t, err)

// expired auth
expiredToken, _, err := authenticator.CreateAuthentication(
context.TODO(),
&auth.CreateAuthenticationRequest{
Method: authrpc.Method_METHOD_TOKEN,
ExpiresAt: timestamppb.New(time.Now().UTC().Add(-time.Hour)),
},
&storageauth.CreateAuthenticationRequest{Method: authrpc.Method_METHOD_TOKEN},
)
require.NoError(t, err)

for _, test := range []struct {
name string
metadata metadata.MD
server any
options []containers.Option[InterceptorOptions]
expectedErr error
expectedAuth *authrpc.Authentication
}{
{
name: "successful authentication",
name: "successful authentication (authorization header)",
metadata: metadata.MD{
"Authorization": []string{"Bearer " + clientToken},
},
expectedAuth: storedAuth,
},
{
name: "token has expired",
name: "successful authentication (cookie header)",
metadata: metadata.MD{
"Authorization": []string{"Bearer " + expiredToken},
"cookie": []string{"flipt_client_token=" + clientToken},
},
expectedAuth: storedAuth,
},
{
name: "successful authentication (skipped)",
metadata: metadata.MD{},
server: &fakeserver,
options: []containers.Option[InterceptorOptions]{
WithServerSkipsAuthentication(&fakeserver),
},
expectedErr: errUnauthenticated,
},
{
name: "client token not found in store",
Expand All @@ -76,6 +77,13 @@ func TestUnaryInterceptor(t *testing.T) {
},
expectedErr: errUnauthenticated,
},
{
name: "cookie header with no flipt_client_token",
metadata: metadata.MD{
"Cookie": []string{"blah"},
},
expectedErr: errUnauthenticated,
},
{
name: "authorization header not set",
metadata: metadata.MD{},
Expand Down Expand Up @@ -105,10 +113,10 @@ func TestUnaryInterceptor(t *testing.T) {
ctx = metadata.NewIncomingContext(ctx, test.metadata)
}

_, err := UnaryInterceptor(logger, authenticator)(
_, err := UnaryInterceptor(logger, authenticator, test.options...)(
ctx,
nil,
nil,
&grpc.UnaryServerInfo{Server: test.server},
handler,
)
require.Equal(t, test.expectedErr, err)
Expand Down