Skip to content
This repository was archived by the owner on Jun 11, 2022. It is now read-only.

Commit

Permalink
Add authenticator for use with grpc.
Browse files Browse the repository at this point in the history
I wrote this during the CMS-to-AWS project and now need it elsewhere, so
I've extracted it.
  • Loading branch information
dpetersen committed Aug 23, 2016
1 parent 7e5411f commit e4aa375
Show file tree
Hide file tree
Showing 2 changed files with 355 additions and 0 deletions.
153 changes: 153 additions & 0 deletions grpcg5auth/authenticator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package grpcg5auth

import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"

"google.golang.org/grpc/metadata"

"golang.org/x/net/context"
)

const serviceToServiceIdentity = "service-to-service"

var (
authTimeout = 5 * time.Second
employeeDomains = []string{
"getg5.com",
"g5platform.com",
"g5searchmarketing.com",
}
// exists entirely for tests, which run over http
authProtocol = "https"
)

// G5AuthenticatorConfig holds (mostly) optional configuration for how your app
// will communicate with G5 Auth, or how it will authenticate clients.
type G5AuthenticatorConfig struct {
// How long to wait for auth's response
TimeoutDuration time.Duration
// A token you will accept in lieu of a real bearer token when
// service-to-service calls really have to be quick. This is probably a bad
// idea. Roll your own crypto? SURE!
MagicalTokenOfSupremePower string
// The hostname of the auth server, sans protocol.
AuthHostname string
}

// An Authenticator can accepts a context which should contain credentials in
// its metadata, and will return of a copy of that context with identity
// metadata for the authorized person.
type Authenticator interface {
IdentifyContext(context.Context) (context.Context, error)
}

// G5Authenticator is an implementation of Authenticator that finds email
// addresses from oauth tokens via G5 Auth.
type G5Authenticator struct {
config G5AuthenticatorConfig
client *http.Client
}

// NewG5Authenticator creates a G5Authenticator configured with an http.Client.
func NewG5Authenticator(c G5AuthenticatorConfig) *G5Authenticator {
return &G5Authenticator{
config: c,
client: &http.Client{
Timeout: c.TimeoutDuration,
},
}
}

type identityResponse struct {
Email string
}

// IdentifyContext takes a context and will verify the token in its metadata
// with G5 Auth, populating the person's email address in the returned
// context's metadata. It will throw an error if there are any failures
// authenticating, problems with the metadata, or errors connecting to G5 Auth.
func (a *G5Authenticator) IdentifyContext(ctx context.Context) (context.Context, error) {
md, ok := metadata.FromContext(ctx)
if !ok {
return nil, errors.New("no metadata in request")
}

authorizations := md["authorization"]
if i := len(authorizations); i != 1 {
return nil, fmt.Errorf("unexpected number of authorization metadatum: %d", i)
}

parts := strings.Split(authorizations[0], " ")
if len(parts) != 2 {
return nil, errors.New("bad authorization format")
}

switch parts[0] {
case "magic":
if a.config.MagicalTokenOfSupremePower == "" {
return nil, errors.New("magic auth is not configured")
}
if parts[1] == a.config.MagicalTokenOfSupremePower {
return context.WithValue(ctx, "identity", serviceToServiceIdentity), nil
}
return nil, errors.New("bad magic token of supreme power")
case "bearer":
return a.authenticateBearerToken(parts[1], ctx)
default:
return nil, errors.New("unknown token type")
}
}

func (a *G5Authenticator) authenticateBearerToken(token string, ctx context.Context) (context.Context, error) {
meURL := fmt.Sprintf("%s://%s/v1/me", authProtocol, a.config.AuthHostname)
req, err := http.NewRequest("GET", meURL, nil)
if err != nil {
return nil, fmt.Errorf("building identity request: %v", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))

resp, err := a.client.Do(req)
if err != nil {
return nil, fmt.Errorf("making request: %v", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status: %s", resp.Status)
}

me := &identityResponse{}
if err := json.NewDecoder(resp.Body).Decode(me); err != nil {
return nil, fmt.Errorf("decoding identity response: %v", err)
}
defer resp.Body.Close()

if me.Email == "" {
return nil, errors.New("no email found in identity")
}

if err := validateDomain(me.Email); err != nil {
return nil, err
}

return context.WithValue(ctx, "identity", me.Email), nil
}

func validateDomain(s string) error {
parts := strings.Split(s, "@")
if len(parts) != 2 {
return fmt.Errorf("unparseable identity email: %s", s)
}
domain := parts[1]

for _, s := range employeeDomains {
if s == domain {
return nil
}
}

return fmt.Errorf("non-employee identity found: %s", s)
}
202 changes: 202 additions & 0 deletions grpcg5auth/authenticator_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package grpcg5auth

import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

"google.golang.org/grpc/metadata"

"github.com/stretchr/testify/assert"

"golang.org/x/net/context"
)

func init() {
authProtocol = "http"
}

type G5AuthenticatorContext struct {
Context context.Context
Authenticator *G5Authenticator
Server *httptest.Server
MeStatus int
MeJSON string
AuthCalled bool
PassedHeader string
}

func NewG5AuthenticatorContext() *G5AuthenticatorContext {
mux := http.NewServeMux()
srv := httptest.NewServer(mux)

authCtx := metadata.NewContext(
context.Background(),
map[string][]string{"authorization": []string{"bearer 12345"}},
)

config := G5AuthenticatorConfig{
TimeoutDuration: 100 * time.Millisecond,
MagicalTokenOfSupremePower: "bacon",
AuthHostname: strings.TrimLeft(srv.URL, "http://"),
}
ctx := &G5AuthenticatorContext{
Context: authCtx,
Authenticator: NewG5Authenticator(config),
Server: srv,
MeStatus: http.StatusOK,
MeJSON: `{"email":"[email protected]"}`,
}

mux.HandleFunc("/v1/me", func(w http.ResponseWriter, r *http.Request) {
ctx.AuthCalled = true
ctx.PassedHeader = r.Header.Get("Authorization")
if ctx.MeStatus != http.StatusOK {
w.WriteHeader(ctx.MeStatus)
return
}
fmt.Fprintf(w, ctx.MeJSON)
})

return ctx
}

func TestG5Authenticator_IdentifyContext(t *testing.T) {
ctx := NewG5AuthenticatorContext()
defer ctx.Server.Close()

idCtx, err := ctx.Authenticator.IdentifyContext(ctx.Context)
assert.NoError(t, err)
assert.Equal(t, "[email protected]", idCtx.Value("identity"))
assert.Equal(t, "Bearer 12345", ctx.PassedHeader)
}

func TestG5Authenticator_IdentifyContext_RespectsMagicalToken(t *testing.T) {
ctx := NewG5AuthenticatorContext()
defer ctx.Server.Close()
ctx.Context = metadata.NewContext(
context.Background(),
map[string][]string{"authorization": []string{"magic bacon"}},
)

idCtx, err := ctx.Authenticator.IdentifyContext(ctx.Context)
assert.NoError(t, err)
assert.Equal(t, serviceToServiceIdentity, idCtx.Value("identity"))
assert.False(t, ctx.AuthCalled)
}

func TestG5Authenticator_IdentifyContext_Error(t *testing.T) {
suite := []struct {
Setup func(*G5AuthenticatorContext)
Msg, Regexp string
}{
{
Setup: func(ctx *G5AuthenticatorContext) {
ctx.Context = context.Background()
},
Msg: "no metadata in request",
},
{
Setup: func(ctx *G5AuthenticatorContext) {
ctx.Context = metadata.NewContext(
context.Background(),
map[string][]string{"authorization": []string{}},
)
},
Msg: "unexpected number of authorization metadatum: 0",
},
{
Setup: func(ctx *G5AuthenticatorContext) {
ctx.Context = metadata.NewContext(
context.Background(),
map[string][]string{"authorization": []string{"unknown whatever"}},
)
},
Msg: "unknown token type",
},
{
Setup: func(ctx *G5AuthenticatorContext) {
ctx.Context = metadata.NewContext(
context.Background(),
map[string][]string{"authorization": []string{"whatevenaretokens"}},
)
},
Msg: "bad authorization format",
},
{
Setup: func(ctx *G5AuthenticatorContext) {
ctx.Context = metadata.NewContext(
context.Background(),
map[string][]string{"authorization": []string{"magic bad"}},
)
},
Msg: "bad magic token of supreme power",
},
{
Setup: func(ctx *G5AuthenticatorContext) {
ctx.Context = metadata.NewContext(
context.Background(),
map[string][]string{"authorization": []string{"magic "}},
)
ctx.Authenticator.config.MagicalTokenOfSupremePower = ""
},
Msg: "magic auth is not configured",
},
{
Setup: func(ctx *G5AuthenticatorContext) {
ctx.Server.Close()
},
Regexp: `making request:.+connection refused`,
},
{
Setup: func(ctx *G5AuthenticatorContext) {
ctx.MeStatus = http.StatusUnauthorized
},
Msg: "unexpected status: 401 Unauthorized",
},
{
Setup: func(ctx *G5AuthenticatorContext) {
ctx.MeJSON = "bad"
},
Msg: "decoding identity response: invalid character 'b' looking for beginning of value",
},
{
Setup: func(ctx *G5AuthenticatorContext) {
ctx.MeJSON = `{}`
},
Msg: "no email found in identity",
},
{
Setup: func(ctx *G5AuthenticatorContext) {
ctx.MeJSON = `{"email":"whatever"}`
},
Msg: "unparseable identity email: whatever",
},
{
Setup: func(ctx *G5AuthenticatorContext) {
ctx.MeJSON = `{"email":"[email protected]"}`
},
Msg: "non-employee identity found: [email protected]",
},
}

for _, test := range suite {
t.Run(test.Msg, func(t *testing.T) {
ctx := NewG5AuthenticatorContext()
test.Setup(ctx)

idCtx, err := ctx.Authenticator.IdentifyContext(ctx.Context)
assert.Nil(t, idCtx)
if test.Msg != "" {
assert.EqualError(t, err, test.Msg)
} else {
assert.Regexp(t, test.Regexp, err.Error())
}

ctx.Server.Close()
})
}
}

0 comments on commit e4aa375

Please sign in to comment.