tokenserver: Implement MintOAuthTokenGrant RPC.

Most of it anyway. Lacks logging to BigQuery.

R=smut@google.com
BUG=731843

Review-Url: https://codereview.chromium.org/2991413002
diff --git a/tokenserver/appengine/impl/serviceaccounts/grant.go b/tokenserver/appengine/impl/serviceaccounts/grant.go
new file mode 100644
index 0000000..946940f
--- /dev/null
+++ b/tokenserver/appengine/impl/serviceaccounts/grant.go
@@ -0,0 +1,51 @@
+// Copyright 2017 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package serviceaccounts
+
+import (
+	"github.com/golang/protobuf/proto"
+	"golang.org/x/net/context"
+
+	"github.com/luci/luci-go/server/auth/signing"
+
+	"github.com/luci/luci-go/tokenserver/api"
+	"github.com/luci/luci-go/tokenserver/appengine/impl/utils/tokensigning"
+)
+
+// tokenSigningContext is used to make sure grant token is not misused in
+// place of some other token.
+//
+// See SigningContext in utils/tokensigning.Signer.
+const tokenSigningContext = "LUCI OAuthTokenGrant v1"
+
+// SignGrant signs and serializes the OAuth grant.
+//
+// It doesn't do any validation. Assumes the prepared body is valid.
+//
+// Produces base64 URL-safe token or a transient error.
+func SignGrant(c context.Context, signer signing.Signer, tok *tokenserver.OAuthTokenGrantBody) (string, error) {
+	s := tokensigning.Signer{
+		Signer:         signer,
+		SigningContext: tokenSigningContext,
+		Wrap: func(w *tokensigning.Unwrapped) proto.Message {
+			return &tokenserver.OAuthTokenGrantEnvelope{
+				TokenBody:      w.Body,
+				Pkcs1Sha256Sig: w.RsaSHA256Sig,
+				KeyId:          w.KeyID,
+			}
+		},
+	}
+	return s.SignToken(c, tok)
+}
diff --git a/tokenserver/appengine/impl/serviceaccounts/grant_test.go b/tokenserver/appengine/impl/serviceaccounts/grant_test.go
new file mode 100644
index 0000000..32317fb
--- /dev/null
+++ b/tokenserver/appengine/impl/serviceaccounts/grant_test.go
@@ -0,0 +1,86 @@
+// Copyright 2017 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package serviceaccounts
+
+import (
+	"encoding/base64"
+	"testing"
+
+	"github.com/golang/protobuf/proto"
+	"golang.org/x/net/context"
+
+	"github.com/luci/luci-go/server/auth/signing"
+	"github.com/luci/luci-go/server/auth/signing/signingtest"
+	"github.com/luci/luci-go/tokenserver/api"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestSignGrant(t *testing.T) {
+	Convey("Works", t, func() {
+		ctx := context.Background()
+		signer := signingtest.NewSigner(0, nil)
+
+		original := &tokenserver.OAuthTokenGrantBody{
+			TokenId:        123,
+			ServiceAccount: "email@example.com",
+			Proxy:          "user:someone@example.com",
+			EndUser:        "user:someone-else@example.com",
+		}
+
+		tok, err := SignGrant(ctx, signer, original)
+		So(err, ShouldBeNil)
+		So(tok, ShouldEqual, `Ck4IexIRZW1haWxAZXhhbXBsZS5jb20aGHVzZXI6c29tZW9uZUB`+
+			`leGFtcGxlLmNvbSIddXNlcjpzb21lb25lLWVsc2VAZXhhbXBsZS5jb20SKGY5ZGE1YTBkM`+
+			`DkwM2JkYTU4YzZkNjY0ZTM4NTJhODljMjgzZDdmZTkaQIuW0EtCsdP3xNRgnQcWb5DkTvb`+
+			`8Y6xwJLJAQ04PflFeCdBXBxvqVgHbGflYD9OZlNGhUeE40pFpGBPOt4KGxCI`)
+
+		envelope, back, err := deserializeForTest(ctx, tok, signer)
+		So(err, ShouldBeNil)
+		So(back, ShouldResemble, original)
+		So(envelope.KeyId, ShouldEqual, "f9da5a0d0903bda58c6d664e3852a89c283d7fe9")
+	})
+}
+
+func deserializeForTest(c context.Context, tok string, signer signing.Signer) (*tokenserver.OAuthTokenGrantEnvelope, *tokenserver.OAuthTokenGrantBody, error) {
+	blob, err := base64.RawURLEncoding.DecodeString(tok)
+	if err != nil {
+		return nil, nil, err
+	}
+	env := &tokenserver.OAuthTokenGrantEnvelope{}
+	if err = proto.Unmarshal(blob, env); err != nil {
+		return nil, nil, err
+	}
+
+	// See tokensigning.Signer. We prepend tokenSigningContext (+ \x00) before
+	// a message to be signed.
+	bytesToCheck := []byte(tokenSigningContext)
+	bytesToCheck = append(bytesToCheck, 0)
+	bytesToCheck = append(bytesToCheck, env.TokenBody...)
+
+	certs, err := signer.Certificates(c)
+	if err != nil {
+		return nil, nil, err
+	}
+	if err = certs.CheckSignature(env.KeyId, bytesToCheck, env.Pkcs1Sha256Sig); err != nil {
+		return nil, nil, err
+	}
+
+	body := &tokenserver.OAuthTokenGrantBody{}
+	if err = proto.Unmarshal(env.TokenBody, body); err != nil {
+		return nil, nil, err
+	}
+	return env, body, nil
+}
diff --git a/tokenserver/appengine/impl/serviceaccounts/rpc_mint_oauth_token_grant.go b/tokenserver/appengine/impl/serviceaccounts/rpc_mint_oauth_token_grant.go
index c111a16..dbbaa5d 100644
--- a/tokenserver/appengine/impl/serviceaccounts/rpc_mint_oauth_token_grant.go
+++ b/tokenserver/appengine/impl/serviceaccounts/rpc_mint_oauth_token_grant.go
@@ -5,18 +5,206 @@
 package serviceaccounts
 
 import (
+	"fmt"
+	"time"
+
+	"github.com/golang/protobuf/jsonpb"
 	"golang.org/x/net/context"
 	"google.golang.org/grpc"
 	"google.golang.org/grpc/codes"
 
+	"github.com/luci/luci-go/common/clock"
+	"github.com/luci/luci-go/common/logging"
+	"github.com/luci/luci-go/common/proto/google"
+	"github.com/luci/luci-go/server/auth"
+	"github.com/luci/luci-go/server/auth/identity"
+	"github.com/luci/luci-go/server/auth/signing"
+
+	"github.com/luci/luci-go/tokenserver/api"
 	"github.com/luci/luci-go/tokenserver/api/minter/v1"
+	"github.com/luci/luci-go/tokenserver/appengine/impl/utils"
+	"github.com/luci/luci-go/tokenserver/appengine/impl/utils/revocation"
 )
 
+// tokenIDSequenceKind defines the namespace of int64 IDs for grant tokens.
+//
+// Changing it will effectively reset the ID generation.
+const tokenIDSequenceKind = "oauthTokenGrantID"
+
 // MintOAuthTokenGrantRPC implements TokenMinter.MintOAuthTokenGrant method.
 type MintOAuthTokenGrantRPC struct {
+	// Signer is mocked in tests.
+	//
+	// In prod it is gaesigner.Signer.
+	Signer signing.Signer
+
+	// Rules returns service account rules to use for the request.
+	//
+	// In prod it is GlobalRulesCache.Rules.
+	Rules func(context.Context) (*Rules, error)
+
+	// mintMock call is used in tests.
+	//
+	// In prod it is 'mint'
+	mintMock func(context.Context, *mintParams) (*minter.MintOAuthTokenGrantResponse, error)
 }
 
 // MintOAuthTokenGrant produces new OAuth token grant.
 func (r *MintOAuthTokenGrantRPC) MintOAuthTokenGrant(c context.Context, req *minter.MintOAuthTokenGrantRequest) (*minter.MintOAuthTokenGrantResponse, error) {
-	return nil, grpc.Errorf(codes.Unavailable, "not implemented")
+	state := auth.GetState(c)
+
+	// Dump the whole request and relevant auth state to the debug log.
+	callerID := state.User().Identity
+	if logging.IsLogging(c, logging.Debug) {
+		m := jsonpb.Marshaler{Indent: "  "}
+		dump, _ := m.MarshalToString(req)
+		logging.Debugf(c, "Identity: %s", callerID)
+		logging.Debugf(c, "MintOAuthTokenGrantRequest:\n%s", dump)
+	}
+
+	// Grab a string that identifies token server version. This almost always
+	// just hits local memory cache.
+	serviceVer, err := utils.ServiceVersion(c, r.Signer)
+	if err != nil {
+		return nil, grpc.Errorf(codes.Internal, "can't grab service version - %s", err)
+	}
+
+	// Reject obviously bad requests (and parse end_user along the way).
+	switch {
+	case req.ServiceAccount == "":
+		err = fmt.Errorf("service_account is required")
+	case req.ValidityDuration < 0:
+		err = fmt.Errorf("validity_duration must be positive, not %d", req.ValidityDuration)
+	case req.EndUser == "":
+		err = fmt.Errorf("end_user is required")
+	}
+	var endUserID identity.Identity
+	if err == nil {
+		if endUserID, err = identity.MakeIdentity(req.EndUser); err != nil {
+			err = fmt.Errorf("bad end_user - %s", err)
+		}
+	}
+	if err != nil {
+		logging.WithError(err).Errorf(c, "Bad request")
+		return nil, grpc.Errorf(codes.InvalidArgument, "bad request - %s", err)
+	}
+
+	// TODO(vadimsh): Verify that this user is present by requiring the end user's
+	// credentials, e.g make Swarming forward user's OAuth token to the token
+	// server, so it can be validated here.
+
+	// Fetch service account rules. They are hot in memory most of the time.
+	rules, err := r.Rules(c)
+	if err != nil {
+		// Don't put error details in the message, it may be returned to
+		// unauthorized callers.
+		logging.WithError(err).Errorf(c, "Failed to load service accounts rules")
+		return nil, grpc.Errorf(codes.Internal, "failed to load service accounts rules")
+	}
+
+	// Grab the rule for this account. Don't leak information about presence or
+	// absence of the account to the caller, they may not be authorized to see the
+	// account at all.
+	rule := rules.Rule(req.ServiceAccount)
+	if rule == nil {
+		logging.Errorf(c, "No rule for service account %q in the config rev %s", req.ServiceAccount, rules.ConfigRevision())
+		return nil, grpc.Errorf(codes.PermissionDenied, "unknown service account or not enough permissions to use it")
+	}
+	logging.Infof(c, "Found the matching rule %q in the config rev %s", rule.Rule.Name, rules.ConfigRevision())
+
+	// If the caller is in 'Proxies' list, we assume it's known to us and we trust
+	// it enough to start returning more detailed error messages.
+	switch known, err := rule.Proxies.IsMember(c, callerID); {
+	case err != nil:
+		logging.WithError(err).Errorf(c, "Failed to check membership of caller %q", callerID)
+		return nil, grpc.Errorf(codes.Internal, "membership check failed")
+	case !known:
+		logging.Errorf(c, "Caller %q is not authorized to use account %q", callerID, req.ServiceAccount)
+		return nil, grpc.Errorf(codes.PermissionDenied, "unknown service account or not enough permissions to use it")
+	}
+
+	// Check ValidityDuration next, it is easiest check.
+	if req.ValidityDuration == 0 {
+		req.ValidityDuration = 3600
+	}
+	if req.ValidityDuration > rule.Rule.MaxGrantValidityDuration {
+		logging.Errorf(c, "Requested validity is larger than max allowed: %d > %d", req.ValidityDuration, rule.Rule.MaxGrantValidityDuration)
+		return nil, grpc.Errorf(codes.InvalidArgument, "per rule %q the validity duration should be <= %d", rule.Rule.Name, rule.Rule.MaxGrantValidityDuration)
+	}
+
+	// Next is EndUsers check (involves membership lookups).
+	switch known, err := rule.EndUsers.IsMember(c, endUserID); {
+	case err != nil:
+		logging.WithError(err).Errorf(c, "Failed to check membership of end user %q", endUserID)
+		return nil, grpc.Errorf(codes.Internal, "membership check failed")
+	case !known:
+		logging.Errorf(c, "End user %q is not authorized to use account %q", endUserID, req.ServiceAccount)
+		return nil, grpc.Errorf(
+			codes.PermissionDenied, "per rule %q the user %q is not authorized to use the service account %q",
+			rule.Rule.Name, endUserID, req.ServiceAccount)
+	}
+
+	// All checks are done! Note that AllowedScopes is checked later during
+	// MintOAuthTokenViaGrant. Here we don't even know what OAuth scopes will be
+	// requested.
+	var resp *minter.MintOAuthTokenGrantResponse
+	p := mintParams{
+		serviceAccount:   req.ServiceAccount,
+		proxyID:          callerID,
+		endUserID:        endUserID,
+		validityDuration: req.ValidityDuration,
+		serviceVer:       serviceVer,
+	}
+	if r.mintMock != nil {
+		resp, err = r.mintMock(c, &p)
+	} else {
+		resp, err = r.mint(c, &p)
+	}
+	if err != nil {
+		return nil, err
+	}
+
+	// TODO(vadimsh): Log the generated token to BigQuery.
+
+	return resp, nil
+}
+
+type mintParams struct {
+	serviceAccount   string
+	proxyID          identity.Identity
+	endUserID        identity.Identity
+	validityDuration int64
+	serviceVer       string
+}
+
+// mint is called to make the token after the request has been authorized.
+func (r *MintOAuthTokenGrantRPC) mint(c context.Context, p *mintParams) (*minter.MintOAuthTokenGrantResponse, error) {
+	id, err := revocation.GenerateTokenID(c, tokenIDSequenceKind)
+	if err != nil {
+		logging.WithError(err).Errorf(c, "Error when generating token ID")
+		return nil, grpc.Errorf(codes.Internal, "error when generating token ID - %s", err)
+	}
+
+	now := clock.Now(c).UTC()
+	expiry := now.Add(time.Duration(p.validityDuration) * time.Second)
+
+	// All the stuff here has already been validated in 'MintOAuthTokenGrant'.
+	signed, err := SignGrant(c, r.Signer, &tokenserver.OAuthTokenGrantBody{
+		TokenId:          id,
+		ServiceAccount:   p.serviceAccount,
+		Proxy:            string(p.proxyID),
+		EndUser:          string(p.endUserID),
+		IssuedAt:         google.NewTimestamp(now),
+		ValidityDuration: p.validityDuration,
+	})
+	if err != nil {
+		logging.WithError(err).Errorf(c, "Error when signing the token")
+		return nil, grpc.Errorf(codes.Internal, "error when signing the token - %s", err)
+	}
+
+	return &minter.MintOAuthTokenGrantResponse{
+		GrantToken:     signed,
+		Expiry:         google.NewTimestamp(expiry),
+		ServiceVersion: p.serviceVer,
+	}, nil
 }
diff --git a/tokenserver/appengine/impl/serviceaccounts/rpc_mint_oauth_token_grant_test.go b/tokenserver/appengine/impl/serviceaccounts/rpc_mint_oauth_token_grant_test.go
new file mode 100644
index 0000000..d04bafb
--- /dev/null
+++ b/tokenserver/appengine/impl/serviceaccounts/rpc_mint_oauth_token_grant_test.go
@@ -0,0 +1,177 @@
+// Copyright 2017 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package serviceaccounts
+
+import (
+	"net"
+	"testing"
+	"time"
+
+	"golang.org/x/net/context"
+
+	"github.com/luci/gae/service/info"
+	"github.com/luci/luci-go/appengine/gaetesting"
+	"github.com/luci/luci-go/server/auth"
+	"github.com/luci/luci-go/server/auth/authdb"
+	"github.com/luci/luci-go/server/auth/authtest"
+	"github.com/luci/luci-go/server/auth/signing"
+	"github.com/luci/luci-go/server/auth/signing/signingtest"
+	"github.com/luci/luci-go/tokenserver/api/minter/v1"
+
+	"github.com/luci/luci-go/common/clock"
+	"github.com/luci/luci-go/common/clock/testclock"
+	"github.com/luci/luci-go/common/proto/google"
+
+	. "github.com/luci/luci-go/common/testing/assertions"
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func testingContext() context.Context {
+	ctx := gaetesting.TestingContext()
+	ctx = info.GetTestable(ctx).SetRequestID("gae-request-id")
+	ctx, _ = testclock.UseTime(ctx, time.Date(2015, time.February, 3, 4, 5, 6, 0, time.UTC))
+	return auth.WithState(ctx, &authtest.FakeState{
+		Identity:       "user:requestor@example.com",
+		PeerIPOverride: net.ParseIP("127.10.10.10"),
+		FakeDB:         &authdb.SnapshotDB{Rev: 1234},
+	})
+}
+
+func testingSigner() signing.Signer {
+	return signingtest.NewSigner(0, &signing.ServiceInfo{
+		ServiceAccountName: "signer@testing.host",
+		AppID:              "unit-tests",
+		AppVersion:         "mocked-ver",
+	})
+}
+
+func TestMintOAuthTokenGrant(t *testing.T) {
+	t.Parallel()
+
+	ctx := testingContext()
+
+	Convey("with mocked config and state", t, func() {
+		cfg, err := loadConfig(`rules {
+			name: "rule 1"
+			service_account: "account@robots.com"
+			proxy: "user:requestor@example.com"
+			end_user: "user:enduser@example.com"
+			max_grant_validity_duration: 7200
+		}`)
+		So(err, ShouldBeNil)
+
+		var lastParams *mintParams
+		mintMock := func(c context.Context, p *mintParams) (*minter.MintOAuthTokenGrantResponse, error) {
+			lastParams = p
+			expiry := clock.Now(c).Add(time.Duration(p.validityDuration) * time.Second)
+			return &minter.MintOAuthTokenGrantResponse{
+				GrantToken:     "valid_token",
+				Expiry:         google.NewTimestamp(expiry),
+				ServiceVersion: p.serviceVer,
+			}, nil
+		}
+
+		rpc := MintOAuthTokenGrantRPC{
+			Signer:   testingSigner(),
+			Rules:    func(context.Context) (*Rules, error) { return cfg, nil },
+			mintMock: mintMock,
+		}
+
+		Convey("Happy path", func() {
+			resp, err := rpc.MintOAuthTokenGrant(ctx, &minter.MintOAuthTokenGrantRequest{
+				ServiceAccount: "account@robots.com",
+				EndUser:        "user:enduser@example.com",
+			})
+			So(err, ShouldBeNil)
+			So(resp.GrantToken, ShouldEqual, "valid_token")
+			So(resp.ServiceVersion, ShouldEqual, "unit-tests/mocked-ver")
+			So(lastParams, ShouldResemble, &mintParams{
+				serviceAccount:   "account@robots.com",
+				proxyID:          "user:requestor@example.com",
+				endUserID:        "user:enduser@example.com",
+				validityDuration: 3600, // default
+				serviceVer:       "unit-tests/mocked-ver",
+			})
+		})
+
+		Convey("Empty service account", func() {
+			_, err := rpc.MintOAuthTokenGrant(ctx, &minter.MintOAuthTokenGrantRequest{
+				EndUser: "user:enduser@example.com",
+			})
+			So(err, ShouldBeRPCInvalidArgument, "service_account is required")
+		})
+
+		Convey("Negative validity", func() {
+			_, err := rpc.MintOAuthTokenGrant(ctx, &minter.MintOAuthTokenGrantRequest{
+				ServiceAccount:   "account@robots.com",
+				EndUser:          "user:enduser@example.com",
+				ValidityDuration: -1,
+			})
+			So(err, ShouldBeRPCInvalidArgument, "validity_duration must be positive")
+		})
+
+		Convey("Empty end-user", func() {
+			_, err := rpc.MintOAuthTokenGrant(ctx, &minter.MintOAuthTokenGrantRequest{
+				ServiceAccount: "account@robots.com",
+			})
+			So(err, ShouldBeRPCInvalidArgument, "end_user is required")
+		})
+
+		Convey("Bad end-user", func() {
+			_, err := rpc.MintOAuthTokenGrant(ctx, &minter.MintOAuthTokenGrantRequest{
+				ServiceAccount: "account@robots.com",
+				EndUser:        "blah",
+			})
+			So(err, ShouldBeRPCInvalidArgument, "bad identity string")
+		})
+
+		Convey("Unknown rule", func() {
+			_, err := rpc.MintOAuthTokenGrant(ctx, &minter.MintOAuthTokenGrantRequest{
+				ServiceAccount: "unknown@robots.com",
+				EndUser:        "user:enduser@example.com",
+			})
+			So(err, ShouldBeRPCPermissionDenied, "unknown service account or not enough permissions to use it")
+		})
+
+		Convey("Unauthorized caller", func() {
+			ctx := auth.WithState(ctx, &authtest.FakeState{
+				Identity: "user:unknown@example.com",
+			})
+			_, err := rpc.MintOAuthTokenGrant(ctx, &minter.MintOAuthTokenGrantRequest{
+				ServiceAccount: "account@robots.com",
+				EndUser:        "user:enduser@example.com",
+			})
+			So(err, ShouldBeRPCPermissionDenied, "unknown service account or not enough permissions to use it")
+		})
+
+		Convey("Too high validity duration", func() {
+			_, err := rpc.MintOAuthTokenGrant(ctx, &minter.MintOAuthTokenGrantRequest{
+				ServiceAccount:   "account@robots.com",
+				EndUser:          "user:enduser@example.com",
+				ValidityDuration: 7201,
+			})
+			So(err, ShouldBeRPCInvalidArgument, `per rule "rule 1" the validity duration should be <= 7200`)
+		})
+
+		Convey("Unauthorized end-user", func() {
+			_, err := rpc.MintOAuthTokenGrant(ctx, &minter.MintOAuthTokenGrantRequest{
+				ServiceAccount: "account@robots.com",
+				EndUser:        "user:unknown@example.com",
+			})
+			So(err, ShouldBeRPCPermissionDenied,
+				`per rule "rule 1" the user "user:unknown@example.com" is not authorized to use the service account "account@robots.com"`)
+		})
+	})
+}
diff --git a/tokenserver/appengine/impl/services/minter/tokenminter/service.go b/tokenserver/appengine/impl/services/minter/tokenminter/service.go
index ccab37e..10a3d39 100644
--- a/tokenserver/appengine/impl/services/minter/tokenminter/service.go
+++ b/tokenserver/appengine/impl/services/minter/tokenminter/service.go
@@ -54,5 +54,9 @@
 			Rules:    delegation.GlobalRulesCache.Rules,
 			LogToken: delegation.LogToken,
 		},
+		MintOAuthTokenGrantRPC: serviceaccounts.MintOAuthTokenGrantRPC{
+			Signer: gaesigner.Signer{},
+			Rules:  serviceaccounts.GlobalRulesCache.Rules,
+		},
 	}
 }