internal/access: add access package
This change adds an access package which is intented to contain
functions which will handle Identity Aware Proxy authentication. It
may be extended to include authorization logic in the future.
Fixes golang/go#48729
Updates golang/go#47521
Change-Id: I68cd90c3e83066763e3194fcb58e324c3630f811
Reviewed-on: https://go-review.googlesource.com/c/build/+/358915
Reviewed-by: Heschi Kreinick <heschi@google.com>
Reviewed-by: Alexander Rakoczy <alex@golang.org>
Trust: Alexander Rakoczy <alex@golang.org>
Run-TryBot: Alexander Rakoczy <alex@golang.org>
TryBot-Result: Go Bot <gobot@golang.org>
diff --git a/go.mod b/go.mod
index 409189d..96894ec 100644
--- a/go.mod
+++ b/go.mod
@@ -27,6 +27,7 @@
github.com/googleapis/gax-go/v2 v2.0.5
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7
+ github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4
github.com/jackc/pgconn v1.10.0
github.com/jackc/pgx/v4 v4.13.0
github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1
diff --git a/go.sum b/go.sum
index 3ceb940..2b6beb1 100644
--- a/go.sum
+++ b/go.sum
@@ -382,6 +382,7 @@
github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7 h1:pdN6V1QBWetyv/0+wjACpqVH+eVULgEjkurDLq3goeM=
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
+github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4 h1:z53tR0945TRRQO/fLEVPI6SMv7ZflF0TEaTAoU7tOzg=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
diff --git a/internal/access/access.go b/internal/access/access.go
new file mode 100644
index 0000000..17c2c77
--- /dev/null
+++ b/internal/access/access.go
@@ -0,0 +1,127 @@
+// Copyright 2021 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package access
+
+import (
+ "context"
+ "fmt"
+ "log"
+
+ grpcauth "github.com/grpc-ecosystem/go-grpc-middleware/auth"
+ "google.golang.org/api/idtoken"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/metadata"
+ "google.golang.org/grpc/status"
+)
+
+type contextKeyIAP string
+
+const (
+ // contextIAP is the key used to store IAP provided fields in the context.
+ contextIAP contextKeyIAP = contextKeyIAP("IAP-JWT")
+
+ // IAPHeaderJWT is the header IAP stores the JWT token in.
+ iapHeaderJWT = "X-Goog-IAP-JWT-Assertion"
+ // iapHeaderEmail is the header IAP stores the email in.
+ iapHeaderEmail = "X-Goog-Authenticated-User-Email"
+ // iapHeaderID is the header IAP stores the user id in.
+ iapHeaderID = "X-Goog-Authenticated-User-Id"
+)
+
+// IAPFields contains the values for the headers retrieved from Identity Aware
+// Proxy.
+type IAPFields struct {
+ Email string
+ ID string
+}
+
+// IAPFromContext retrieves the IAPFields stored in the context if it exists.
+func IAPFromContext(ctx context.Context) (*IAPFields, error) {
+ v := ctx.Value(contextIAP)
+ if v == nil {
+ return nil, fmt.Errorf("IAP fields not found in context")
+ }
+ iap, ok := v.(IAPFields)
+ if !ok {
+ return nil, fmt.Errorf("context value retrieved does not match expected type")
+ }
+ return &iap, nil
+}
+
+// iapAuthFunc creates an authentication function used to create a GRPC interceptor.
+// It ensures that the caller has successfully authenticated via IAP. If the caller
+// has authenticated, the headers created by IAP will be added to the request scope
+// context passed down to the server implementation.
+func iapAuthFunc(audience string, validatorFn validator) grpcauth.AuthFunc {
+ return func(ctx context.Context) (context.Context, error) {
+ md, ok := metadata.FromIncomingContext(ctx)
+ if !ok {
+ return ctx, status.Error(codes.Internal, codes.Internal.String())
+ }
+ jwt := md.Get(iapHeaderJWT)
+ if len(jwt) == 0 {
+ return ctx, status.Error(codes.Unauthenticated, "IAP JWT not found in request")
+ }
+ var err error
+ if _, err = validatorFn(ctx, jwt[0], audience); err != nil {
+ log.Printf("access: error validating JWT: %s", err)
+ return ctx, status.Error(codes.Unauthenticated, "unable to authenticate")
+ }
+ if ctx, err = contextWithIAPMD(ctx, md); err != nil {
+ log.Printf("access: unable to set IAP fields in context: %s", err)
+ return ctx, status.Error(codes.Unauthenticated, "unable to authenticate")
+ }
+ return ctx, nil
+ }
+}
+
+// contextWithIAPMD copies the headers set by IAP into the context.
+func contextWithIAPMD(ctx context.Context, md metadata.MD) (context.Context, error) {
+ retrieveFn := func(fmd metadata.MD, mdKey string) (string, error) {
+ val := fmd.Get(mdKey)
+ if len(val) == 0 || val[0] == "" {
+ return "", fmt.Errorf("unable to retrieve %s from GRPC metadata", mdKey)
+ }
+ return val[0], nil
+ }
+ var iap IAPFields
+ var err error
+ if iap.Email, err = retrieveFn(md, iapHeaderEmail); err != nil {
+ return ctx, fmt.Errorf("unable to retrieve metadata field: %s", iapHeaderEmail)
+ }
+ if iap.ID, err = retrieveFn(md, iapHeaderID); err != nil {
+ return ctx, fmt.Errorf("unable to retrieve metadata field: %s", iapHeaderID)
+ }
+ return context.WithValue(ctx, contextIAP, iap), nil
+}
+
+// RequireIAPAuthUnaryInterceptor creates an authentication interceptor for a GRPC
+// server. This requires Identity Aware Proxy authentication. Upon a successful authentication
+// the associated headers will be copied into the request context.
+func RequireIAPAuthUnaryInterceptor(audience string) grpc.UnaryServerInterceptor {
+ return grpcauth.UnaryServerInterceptor(iapAuthFunc(audience, idtoken.Validate))
+}
+
+// RequireIAPAuthStreamInterceptor creates an authentication interceptor for a GRPC
+// streaming server. This requires Identity Aware Proxy authentication. Upon a successful
+// authentication the associated headers will be copied into the request context.
+func RequireIAPAuthStreamInterceptor(audience string) grpc.StreamServerInterceptor {
+ return grpcauth.StreamServerInterceptor(iapAuthFunc(audience, idtoken.Validate))
+}
+
+// validator is a function type for the validator function. The primary purpose is to be able to
+// replace the validator function.
+type validator func(ctx context.Context, token, audiance string) (*idtoken.Payload, error)
+
+// IAPAudienceGCE returns the jwt audience for GCE and GKE services.
+func IAPAudienceGCE(projectNumber int64, serviceID string) string {
+ return fmt.Sprintf("/projects/%d/global/backendServices/%s", projectNumber, serviceID)
+}
+
+// IAPAudienceAppEngine returns the JWT audience for App Engine services.
+func IAPAudienceAppEngine(projectNumber int64, projectID string) string {
+ return fmt.Sprintf("/projects/%d/apps/%s", projectNumber, projectID)
+}
diff --git a/internal/access/access_test.go b/internal/access/access_test.go
new file mode 100644
index 0000000..9d21058
--- /dev/null
+++ b/internal/access/access_test.go
@@ -0,0 +1,98 @@
+// Copyright 2021 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package access
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+ "google.golang.org/api/idtoken"
+ "google.golang.org/grpc/metadata"
+)
+
+func TestIAPFromContextError(t *testing.T) {
+ ctx := context.WithValue(context.Background(), contextIAP, "dance party")
+ if got, err := IAPFromContext(ctx); got != nil || err == nil {
+ t.Errorf("IAPFromContext(ctx) = %v, %s; want error", got, err)
+ }
+}
+
+func TestIAPAuthFunc(t *testing.T) {
+ want := &IAPFields{
+ Email: "charlie@brown.com",
+ ID: "chaz.service.moo",
+ }
+ wantJWTToken := "eyJhb.eyJzdDIyfQ.Bh17Fl2gFjyLh6mo1GjqSPnGUg8MRLAE1Vdo3Z3gvdI"
+ wantAudience := "foo/bar/zar"
+ ctx := metadata.NewIncomingContext(context.Background(), metadata.New(map[string]string{
+ iapHeaderJWT: wantJWTToken,
+ iapHeaderEmail: want.Email,
+ iapHeaderID: want.ID,
+ }))
+ testValidator := func(ctx context.Context, token, audience string) (*idtoken.Payload, error) {
+ if token != wantJWTToken || audience != wantAudience {
+ return nil, fmt.Errorf("testValidator(%q, %q); want %q, %q", token, audience, wantJWTToken, wantAudience)
+ }
+ return &idtoken.Payload{}, nil
+ }
+ authFunc := iapAuthFunc(wantAudience, testValidator)
+ gotCtx, err := authFunc(ctx)
+ if err != nil {
+ t.Fatalf("authFunc(ctx) = %+v, %s; want ctx, no error", gotCtx, err)
+ }
+ got, err := IAPFromContext(gotCtx)
+ if err != nil {
+ t.Fatalf("IAPFromContext(ctx) = %+v, %s; want no error", got, err)
+ }
+ if diff := cmp.Diff(got, want); diff != "" {
+ t.Errorf("ctx.Value(%v) mismatch (-got, +want):\n%s", contextIAP, diff)
+ }
+}
+
+func TestContextWithIAPMDError(t *testing.T) {
+ testCases := []struct {
+ desc string
+ md metadata.MD
+ }{
+ {
+ desc: "missing email header",
+ md: metadata.New(map[string]string{
+ iapHeaderJWT: "jwt",
+ iapHeaderID: "id",
+ }),
+ },
+ {
+ desc: "missing id header",
+ md: metadata.New(map[string]string{
+ iapHeaderJWT: "jwt",
+ iapHeaderEmail: "email",
+ }),
+ },
+ }
+ for _, tc := range testCases {
+ t.Run(tc.desc, func(t *testing.T) {
+ ctx, err := contextWithIAPMD(context.Background(), tc.md)
+ if err == nil {
+ t.Errorf("contextWithIAPMD(ctx, %v) = %+v, %s; want ctx, error", tc.md, ctx, err)
+ }
+ })
+ }
+}
+
+func TestIAPAudienceGCE(t *testing.T) {
+ want := "/projects/11/global/backendServices/bar"
+ if got := IAPAudienceGCE(11, "bar"); got != want {
+ t.Errorf("IAPAudienceGCE(11, bar) = %s; want %s", got, want)
+ }
+}
+
+func TestIAPAudience(t *testing.T) {
+ want := "/projects/11/apps/bar"
+ if got := IAPAudienceAppEngine(11, "bar"); got != want {
+ t.Errorf("IAPAudienceAppEngine(11, bar) = %s; want %s", got, want)
+ }
+}
diff --git a/internal/access/doc.go b/internal/access/doc.go
new file mode 100644
index 0000000..5b6e955
--- /dev/null
+++ b/internal/access/doc.go
@@ -0,0 +1,7 @@
+// Copyright 2021 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// package access provides primatives for implementing authentication and
+// authorization.
+package access