This repository was archived by the owner on Jun 11, 2022. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add authenticator for use with grpc.
I wrote this during the CMS-to-AWS project and now need it elsewhere, so I've extracted it.
- Loading branch information
Showing
2 changed files
with
355 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
}) | ||
} | ||
} |