diff --git a/grpcg5auth/authenticator.go b/grpcg5auth/authenticator.go new file mode 100644 index 0000000..364febb --- /dev/null +++ b/grpcg5auth/authenticator.go @@ -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) +} diff --git a/grpcg5auth/authenticator_test.go b/grpcg5auth/authenticator_test.go new file mode 100644 index 0000000..7332b40 --- /dev/null +++ b/grpcg5auth/authenticator_test.go @@ -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":"test@getg5.com"}`, + } + + 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, "test@getg5.com", 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":"test@somecustomer.com"}` + }, + Msg: "non-employee identity found: test@somecustomer.com", + }, + } + + 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() + }) + } +}