Skip to content

Commit

Permalink
Authenticate Interceptor (#133)
Browse files Browse the repository at this point in the history
  • Loading branch information
bbengfort committed Jan 31, 2023
1 parent 49f0761 commit 2990c06
Show file tree
Hide file tree
Showing 7 changed files with 408 additions and 0 deletions.
38 changes: 38 additions & 0 deletions pkg/ensign/contexts/contexts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package contexts

import (
"context"

"github.com/rotationalio/ensign/pkg/quarterdeck/tokens"
)

// Ensign-specific context keys for passing values to concurrent requests
type contextKey uint8

// Allocate context keys to simplify context key usage in Ensign
const (
KeyUnknown contextKey = iota
KeyClaims
)

// WithClaims returns a copy of the parent context with the access claims stored as a
// value on the new context. Users can fetch the claims using the ClaimsFrom function.
func WithClaims(parent context.Context, claims *tokens.Claims) context.Context {
return context.WithValue(parent, KeyClaims, claims)
}

// ClaimsFrom returns the claims from the context if they exist or false if not.
func ClaimsFrom(ctx context.Context) (*tokens.Claims, bool) {
claims, ok := ctx.Value(KeyClaims).(*tokens.Claims)
return claims, ok
}

var contextKeyNames = []string{"unknown", "claims"}

// String returns a human readable representation of the context key for easier debugging.
func (c contextKey) String() string {
if int(c) < len(contextKeyNames) {
return contextKeyNames[c]
}
return contextKeyNames[0]
}
42 changes: 42 additions & 0 deletions pkg/ensign/contexts/contexts_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package contexts_test

import (
"context"
"fmt"
"testing"

"github.com/rotationalio/ensign/pkg/ensign/contexts"
"github.com/rotationalio/ensign/pkg/quarterdeck/tokens"
"github.com/stretchr/testify/require"
)

func TestClaimsContext(t *testing.T) {
claims := &tokens.Claims{
Name: "Barbara Testly",
Email: "btest@testing.io",
}

parent, cancel := context.WithCancel(context.Background())
ctx := contexts.WithClaims(parent, claims)

cmpt, ok := contexts.ClaimsFrom(ctx)
require.True(t, ok)
require.Same(t, claims, cmpt)

cancel()
require.ErrorIs(t, ctx.Err(), context.Canceled)
}

func TestKeyString(t *testing.T) {
testCases := []struct {
key fmt.Stringer
expected string
}{
{contexts.KeyUnknown, "unknown"},
{contexts.KeyClaims, "claims"},
}

for _, tc := range testCases {
require.Equal(t, tc.expected, tc.key.String())
}
}
24 changes: 24 additions & 0 deletions pkg/ensign/contexts/stream.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package contexts

import (
"context"

"google.golang.org/grpc"
)

// Stream allows users to override the context on a grpc.ServerStream handler so that
// it returns a new context rather than the old context. It is advised to use the
// original stream's context as the new context's parent but this method does not
// enforce it and instead simply returns the context specified.
func Stream(s grpc.ServerStream, ctx context.Context) grpc.ServerStream {
return &stream{s, ctx}
}

type stream struct {
grpc.ServerStream
ctx context.Context
}

func (s *stream) Context() context.Context {
return s.ctx
}
34 changes: 34 additions & 0 deletions pkg/ensign/contexts/stream_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package contexts_test

import (
"context"
"testing"

"github.com/rotationalio/ensign/pkg/ensign/contexts"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
)

func TestStream(t *testing.T) {
mock := &MockStream{}
stream := contexts.Stream(mock, context.WithValue(mock.Context(), contexts.KeyUnknown, "bar"))

ctx := stream.Context()
require.Equal(t, "bar", ctx.Value(contexts.KeyUnknown).(string))

mock.cancel()
require.ErrorIs(t, ctx.Err(), context.Canceled)
}

type MockStream struct {
grpc.ServerStream
ctx context.Context
cancel context.CancelFunc
}

func (m *MockStream) Context() context.Context {
if m.ctx == nil {
m.ctx, m.cancel = context.WithCancel(context.Background())
}
return m.ctx
}
120 changes: 120 additions & 0 deletions pkg/ensign/interceptors/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package interceptors

import (
"context"
"strings"

"github.com/rotationalio/ensign/pkg/ensign/contexts"
"github.com/rotationalio/ensign/pkg/quarterdeck/middleware"
"github.com/rotationalio/ensign/pkg/quarterdeck/tokens"
"github.com/rs/zerolog/log"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)

const (
header = "authorization" // MUST BE LOWER CASE!
bearer = "Bearer " // MUST INCLUDE TRAILING SPACE!
)

// Authenticator ensures that the RPC request has a valid Quarterdeck-issued JWT token
// in the credentials metadata of the request, otherwise it stops processing and returns
// an Unauthenticated error. A valid JWT token means that the token is supplied in the
// credentials, is unexpired, was signed by Quarterdeck private keys, and has the
// correct audience and issuer.
//
// This interceptor extracts the claims from the JWT token and adds them to the context
// of the request, ensuring that downstream interceptors and the handlers can access the
// claims without having to parse the JWT token in the credentials.
//
// In order to perform authentication, this middleware fetches public JSON Web Key Sets
// (JWKS) from the authorizing Quarterdeck server and caches them according to the
// Cache-Control or Expires headers in the response. As Quarterdeck keys are rotated,
// the cache must refresh the public keys in a background routine to correctly
// authenticate incoming credentials. Users can control how the JWKS are fetched and
// cached using AuthOptions from the Quarterdeck middleware package.
//
// Both Unary and Streaming interceptors can be returned from this middleware handler.
type Authenticator struct {
conf middleware.AuthOptions
validator tokens.Validator
}

// Create an authenticator to handle both unary and streaming RPC calls, modifying the
// behavior of the authenticator using auth options from Quarterdeck middleware.
func NewAuthenticator(opts ...middleware.AuthOption) (auth *Authenticator, err error) {
auth = &Authenticator{
conf: middleware.NewAuthOptions(opts...),
}

if auth.validator, err = auth.conf.Validator(); err != nil {
return nil, err
}
return auth, nil
}

// Authenticate a request using the access token credentials provided in the metadata.
func (a *Authenticator) authenticate(ctx context.Context) (_ context.Context, err error) {
var (
claims *tokens.Claims
md metadata.MD
ok bool
)

if md, ok = metadata.FromIncomingContext(ctx); !ok {
return nil, status.Error(codes.Unauthenticated, "missing credentials")
}

// Extract the authorization credentials (we expect [at least] 1 JWT token)
values := md[header]
if len(values) == 0 {
return nil, status.Error(codes.Unauthenticated, "missing credentials")
}

// Loop through credentials to find the first valid claims
// NOTE: we only expect one token but are trying to future-proof the interceptor
for _, value := range values {
if !strings.HasPrefix(value, bearer) {
continue
}

token := strings.TrimPrefix(value, bearer)
if claims, err = a.validator.Verify(token); err == nil {
break
}
}

// Check to see if we found any valid claims in the request
if claims == nil {
log.Debug().Err(err).Int("tokens", len(values)).Msg("could not find a valid access token in request")
return nil, status.Error(codes.Unauthenticated, "invalid credentials")
}

// Add the claims to the context so that downstream handlers can access it
return contexts.WithClaims(ctx, claims), nil
}

// Return the Unary interceptor that uses the Authenticator handler.
func (a *Authenticator) Unary(opts ...middleware.AuthOption) grpc.UnaryServerInterceptor {
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (_ interface{}, err error) {
if ctx, err = a.authenticate(ctx); err != nil {
return nil, err
}
return handler(ctx, req)
}
}

// Return the Stream interceptor that uses the Authenticator handler.
func (a *Authenticator) Stream(opts ...middleware.AuthOption) grpc.StreamServerInterceptor {
return func(srv interface{}, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) (err error) {
var ctx context.Context
if ctx, err = a.authenticate(stream.Context()); err != nil {
return err
}

stream = contexts.Stream(stream, ctx)
return handler(srv, stream)
}
}

0 comments on commit 2990c06

Please sign in to comment.