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,
+ },
}
}