From fcfda8f04cd756c4db7aa62205484a78412aec55 Mon Sep 17 00:00:00 2001 From: Chen Shou Date: Thu, 16 Jun 2022 21:51:43 +0000 Subject: [PATCH 01/11] feat: add authentication config to CLI --- pkg/cli/root.go | 6 ++++- pkg/cli/token.go | 49 +++++++++++++++++++++++++++++++++++ pkg/config/cli_config.go | 19 ++++++++++++++ pkg/config/cli_config_test.go | 29 +++++++++++++++++++++ 4 files changed, 102 insertions(+), 1 deletion(-) diff --git a/pkg/cli/root.go b/pkg/cli/root.go index 0c290807..92a72023 100644 --- a/pkg/cli/root.go +++ b/pkg/cli/root.go @@ -47,7 +47,11 @@ func init() { cobra.OnInitialize(initCfg) rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.jvsctl/config.yaml)") rootCmd.PersistentFlags().String("server", "", "overwrite the JVS server address") - viper.BindPFlag("server", rootCmd.PersistentFlags().Lookup("server")) //nolint // not expect err + rootCmd.PersistentFlags().Bool("insecure", false, "use insecure connection to JVS server") + rootCmd.PersistentFlags().Bool("gcloud-cred", true, "use gcloud credential to authenticate with JVS server") + viper.BindPFlag("server", rootCmd.PersistentFlags().Lookup("server")) //nolint // not expect err + viper.BindPFlag("insecure", rootCmd.PersistentFlags().Lookup("insecure")) //nolint // not expect err + viper.BindPFlag("gcloud-cred", rootCmd.PersistentFlags().Lookup("insecure")) //nolint // not expect err rootCmd.AddCommand(tokenCmd) } diff --git a/pkg/cli/token.go b/pkg/cli/token.go index 1e94368d..ae96113f 100644 --- a/pkg/cli/token.go +++ b/pkg/cli/token.go @@ -15,10 +15,20 @@ package cli import ( + "context" + "crypto/tls" + "crypto/x509" "fmt" "time" "github.com/spf13/cobra" + "google.golang.org/api/idtoken" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/credentials/oauth" + + jvsapis "github.com/abcxyz/jvs/apis/v0" ) var ( @@ -35,6 +45,12 @@ var tokenCmd = &cobra.Command{ } func runTokenCmd(cmd *cobra.Command, args []string) error { + conn, err := grpc.Dial(cfg.Server) + if err != nil { + return fmt.Errorf("failed to connect to JVS service: %w", err) + } + jvsapis.NewJVSServiceClient(conn) + return fmt.Errorf("not implemented") } @@ -44,3 +60,36 @@ func init() { tokenCmd.Flags().BoolVar(&breakglass, "breakglass", false, "Whether it will be a breakglass action") tokenCmd.Flags().DurationVar(&ttl, "ttl", time.Hour, "The token time-to-live duration") } + +func dialOpt() (grpc.DialOption, error) { + if cfg.Authentication.Insecure { + return grpc.WithTransportCredentials(insecure.NewCredentials()), nil + } + + // The default. + systemRoots, err := x509.SystemCertPool() + if err != nil { + return nil, fmt.Errorf("failed to load system cert pool: %w", err) + } + //nolint:gosec // We need to support TLS 1.2 for now (G402). + cred := credentials.NewTLS(&tls.Config{ + RootCAs: systemRoots, + }) + return grpc.WithTransportCredentials(cred), nil +} + +func callOpt(ctx context.Context) (grpc.CallOption, error) { + if cfg.Authentication.Insecure { + return nil, nil + } + + ts, err := idtoken.NewTokenSource(ctx, cfg.Server) + if err != nil { + return nil, fmt.Errorf("failed idtoken.NewTokenSource: %w", err) + } + token, err := ts.Token() + if err != nil { + return nil, fmt.Errorf("failed to generate id token: %w", err) + } + return grpc.PerRPCCredentials(oauth.NewOauthAccess(token)), nil +} diff --git a/pkg/config/cli_config.go b/pkg/config/cli_config.go index 2dbf620d..77fe0ce6 100644 --- a/pkg/config/cli_config.go +++ b/pkg/config/cli_config.go @@ -31,6 +31,18 @@ type CLIConfig struct { // Server is the JVS server address. Server string `yaml:"server,omitempty" env:"SERVER,overwrite"` + + // Authentication is the authentication config. + Authentication *CLIAuthentication `yaml:"authentication,omitempty"` +} + +// CLIAuthentication is the CLI authentication config. +type CLIAuthentication struct { + // Insecure indiates whether to use insecured connection to the JVS server. + Insecure bool `yaml:"insecure,omitempty"` + + // GCloud indicates whether to use gcloud idtoken for authentication. + GCloud bool `yaml:"use_gcloud,omitempty"` } // Validate checks if the config is valid. @@ -42,6 +54,10 @@ func (cfg *CLIConfig) Validate() error { err = multierror.Append(err, fmt.Errorf("missing JVS server address")) } + if cfg.Authentication.Insecure && cfg.Authentication.GCloud { + err = multierror.Append(err, fmt.Errorf("only one authentication method can be used")) + } + return err.ErrorOrNil() } @@ -50,4 +66,7 @@ func (cfg *CLIConfig) SetDefault() { if cfg.Version == 0 { cfg.Version = DefaultCLIConfigVersion } + if cfg.Authentication == nil { + cfg.Authentication = &CLIAuthentication{} + } } diff --git a/pkg/config/cli_config_test.go b/pkg/config/cli_config_test.go index a39b104c..d6368e94 100644 --- a/pkg/config/cli_config_test.go +++ b/pkg/config/cli_config_test.go @@ -18,6 +18,7 @@ import ( "testing" "github.com/abcxyz/pkg/testutil" + "github.com/google/go-cmp/cmp" ) func TestValidateCLIConfig(t *testing.T) { @@ -47,3 +48,31 @@ func TestValidateCLIConfig(t *testing.T) { }) } } + +func TestSetDefault(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg *CLIConfig + wantCfg *CLIConfig + }{{ + name: "default_empty_authentication", + cfg: &CLIConfig{}, + wantCfg: &CLIConfig{ + Version: 1, + Authentication: &CLIAuthentication{}, + }, + }} + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + tc.cfg.SetDefault() + if diff := cmp.Diff(tc.wantCfg, tc.cfg); diff != "" { + t.Errorf("config with defaults (-want,+got):\n%s", diff) + } + }) + } +} From ffd61de75a0a545cbf134eff85868ef2a4ff196d Mon Sep 17 00:00:00 2001 From: Chen Shou Date: Fri, 17 Jun 2022 04:32:18 +0000 Subject: [PATCH 02/11] feat: add idtoken source to generate idtoken for end user --- go.mod | 2 +- pkg/idtoken/idtoken.go | 131 ++++++++++++++++++++++++++++++++++++ pkg/idtoken/idtoken_test.go | 46 +++++++++++++ 3 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 pkg/idtoken/idtoken.go create mode 100644 pkg/idtoken/idtoken_test.go diff --git a/go.mod b/go.mod index d0a13617..dca08dc1 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/spf13/viper v1.12.0 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.32.0 go.uber.org/zap v1.21.0 + golang.org/x/oauth2 v0.0.0-20220524215830-622c5d57e401 golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f google.golang.org/api v0.82.0 google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8 @@ -61,7 +62,6 @@ require ( go.uber.org/multierr v1.8.0 // indirect golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f // indirect golang.org/x/net v0.0.0-20220531201128-c960675eff93 // indirect - golang.org/x/oauth2 v0.0.0-20220524215830-622c5d57e401 // indirect golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect golang.org/x/text v0.3.7 // indirect google.golang.org/appengine v1.6.7 // indirect diff --git a/pkg/idtoken/idtoken.go b/pkg/idtoken/idtoken.go new file mode 100644 index 00000000..cc8b1b94 --- /dev/null +++ b/pkg/idtoken/idtoken.go @@ -0,0 +1,131 @@ +// Copyright 2022 Google LLC +// +// 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 idtoken provides functions to generate id tokens for end users. +package idtoken + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" +) + +const ( + // These configs are gcloud configs: + // https://github.com/twistedpair/google-cloud-sdk/blob/master/google-cloud-sdk/lib/googlecloudsdk/core/config.py + CloudSDKClientID = "32555940559.apps.googleusercontent.com" + CloudSDKClientNotSoSecret = "ZmssLNjJy2998hD4CTg2ejr2" + GoogleOAuthTokenURL = "https://oauth2.googleapis.com/token" +) + +// Config is the config to generate id tokens. +type Config struct { + ClientID string + ClientSecret string + TokenURL string + Audience string +} + +// DefaultGoogleConfig is the default config to generate id tokens. +// It uses the same client config as gcloud. +var DefaultGoogleConfig = &Config{ + ClientID: CloudSDKClientID, + ClientSecret: CloudSDKClientNotSoSecret, + TokenURL: GoogleOAuthTokenURL, + Audience: CloudSDKClientID, +} + +// FromDefaultCredentials creates a token source with the application default credentials. +// https://developers.google.com/accounts/docs/application-default-credentials +// It only works when the application default credentials is of an end user. +// Typically it's done with `gcloud auth application-default login`. +func FromDefaultCredentials(ctx context.Context, cfg *Config) (oauth2.TokenSource, error) { + ts, err := google.DefaultTokenSource(ctx) + if err != nil { + return nil, fmt.Errorf("failed to find google default credential: %w", err) + } + + return oauth2.ReuseTokenSource(nil, &tokenSource{ + refreshTokenSource: ts, + cfg: cfg, + }), nil +} + +type tokenSource struct { + refreshTokenSource oauth2.TokenSource + cfg *Config +} + +// Given a refresh token, generate an id token. +// For GCP, the client id and the audience must be in the same project. +// +// With FromDefaultCredentials, we reuse the refresh token from application default credentials. +// It uses the gcloud client id. +// +// TODO: Support custom client id and audience. +// +// For a full flow, reference: https://cloud.google.com/iap/docs/authentication-howto#authenticating_from_a_desktop_app +func (ts *tokenSource) Token() (*oauth2.Token, error) { + rt, err := ts.refreshTokenSource.Token() + if err != nil { + return nil, fmt.Errorf("failed to get refresh token: %w", err) + } + + v := url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {rt.RefreshToken}, + "client_id": {ts.cfg.ClientID}, + "client_secret": {ts.cfg.ClientSecret}, + "audience": {ts.cfg.Audience}, + } + + // Use the refresh token to exchange an id token. + resp, err := http.DefaultClient.Post(ts.cfg.TokenURL, "application/x-www-form-urlencoded", strings.NewReader(v.Encode())) + if err != nil { + return nil, fmt.Errorf("failed to refresh token: %w", err) + } + + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to refresh token: %w", err) + } + + // tokenRes is the JSON response body. + // Interestingly, the actual id token is in its own field, but an oauth2.Token + // only has an AccessToken field. As a result, we need convert it to an oauth2.Token. + var tokenRes struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + IDToken string `json:"id_token"` + ExpiresIn int64 `json:"expires_in"` // relative seconds from now + } + + if err := json.Unmarshal(b, &tokenRes); err != nil { + return nil, fmt.Errorf("failed to refresh token: %w", err) + } + + return &oauth2.Token{ + AccessToken: tokenRes.IDToken, + TokenType: tokenRes.TokenType, + Expiry: time.Now().Add(time.Duration(tokenRes.ExpiresIn) * time.Second), + }, nil +} diff --git a/pkg/idtoken/idtoken_test.go b/pkg/idtoken/idtoken_test.go new file mode 100644 index 00000000..d2e63622 --- /dev/null +++ b/pkg/idtoken/idtoken_test.go @@ -0,0 +1,46 @@ +// Copyright 2022 Google LLC +// +// 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 idtoken + +import ( + "context" + "os" + "testing" + + "github.com/lestrrat-go/jwx/v2/jwt" +) + +// Log in as an end user with gcloud. +// `gcloud auth application-default login` +// Set env var MANUAL_TEST=true to run the test. +func TestFromDefaultCredentials(t *testing.T) { + if os.Getenv("MANUAL_TEST") == "" { + t.Skip("Skip manual test; set env var MANUAL_TEST to enable") + } + + ts, err := FromDefaultCredentials(context.Background(), DefaultGoogleConfig) + if err != nil { + t.Errorf("failed to get ID token source: %v", err) + } + + tk, err := ts.Token() + if err != nil { + t.Errorf("failed to get ID token: %v", err) + } + + if _, err := jwt.Parse([]byte(tk.AccessToken), jwt.WithVerify(false)); err != nil { + t.Errorf("%q not a valid ID token: %v", tk.AccessToken, err) + } +} From 2487c09d0d06055337aef491a149eddcdedd16b3 Mon Sep 17 00:00:00 2001 From: Chen Shou Date: Fri, 17 Jun 2022 04:39:46 +0000 Subject: [PATCH 03/11] comment --- pkg/idtoken/idtoken.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/idtoken/idtoken.go b/pkg/idtoken/idtoken.go index cc8b1b94..15251032 100644 --- a/pkg/idtoken/idtoken.go +++ b/pkg/idtoken/idtoken.go @@ -81,7 +81,7 @@ type tokenSource struct { // With FromDefaultCredentials, we reuse the refresh token from application default credentials. // It uses the gcloud client id. // -// TODO: Support custom client id and audience. +// TODO: Should our CLI support standalone 3-legged OAuth flow w/o relying on gcloud? // // For a full flow, reference: https://cloud.google.com/iap/docs/authentication-howto#authenticating_from_a_desktop_app func (ts *tokenSource) Token() (*oauth2.Token, error) { From a8d888eabf86e1960aaacb7e6dabeba396593383 Mon Sep 17 00:00:00 2001 From: Chen Shou Date: Fri, 17 Jun 2022 04:49:55 +0000 Subject: [PATCH 04/11] modify api --- pkg/idtoken/idtoken.go | 4 ++-- pkg/idtoken/idtoken_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/idtoken/idtoken.go b/pkg/idtoken/idtoken.go index 15251032..3d227be1 100644 --- a/pkg/idtoken/idtoken.go +++ b/pkg/idtoken/idtoken.go @@ -58,7 +58,7 @@ var DefaultGoogleConfig = &Config{ // https://developers.google.com/accounts/docs/application-default-credentials // It only works when the application default credentials is of an end user. // Typically it's done with `gcloud auth application-default login`. -func FromDefaultCredentials(ctx context.Context, cfg *Config) (oauth2.TokenSource, error) { +func FromDefaultCredentials(ctx context.Context) (oauth2.TokenSource, error) { ts, err := google.DefaultTokenSource(ctx) if err != nil { return nil, fmt.Errorf("failed to find google default credential: %w", err) @@ -66,7 +66,7 @@ func FromDefaultCredentials(ctx context.Context, cfg *Config) (oauth2.TokenSourc return oauth2.ReuseTokenSource(nil, &tokenSource{ refreshTokenSource: ts, - cfg: cfg, + cfg: DefaultGoogleConfig, }), nil } diff --git a/pkg/idtoken/idtoken_test.go b/pkg/idtoken/idtoken_test.go index d2e63622..965816db 100644 --- a/pkg/idtoken/idtoken_test.go +++ b/pkg/idtoken/idtoken_test.go @@ -30,7 +30,7 @@ func TestFromDefaultCredentials(t *testing.T) { t.Skip("Skip manual test; set env var MANUAL_TEST to enable") } - ts, err := FromDefaultCredentials(context.Background(), DefaultGoogleConfig) + ts, err := FromDefaultCredentials(context.Background()) if err != nil { t.Errorf("failed to get ID token source: %v", err) } From bac661537e00125d340979412b9e6b0b60d70b3b Mon Sep 17 00:00:00 2001 From: Chen Shou Date: Fri, 17 Jun 2022 17:12:54 +0000 Subject: [PATCH 05/11] temp --- pkg/cli/root.go | 6 ++---- pkg/config/cli_config.go | 7 ------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/pkg/cli/root.go b/pkg/cli/root.go index 92a72023..b215c70f 100644 --- a/pkg/cli/root.go +++ b/pkg/cli/root.go @@ -48,10 +48,8 @@ func init() { rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.jvsctl/config.yaml)") rootCmd.PersistentFlags().String("server", "", "overwrite the JVS server address") rootCmd.PersistentFlags().Bool("insecure", false, "use insecure connection to JVS server") - rootCmd.PersistentFlags().Bool("gcloud-cred", true, "use gcloud credential to authenticate with JVS server") - viper.BindPFlag("server", rootCmd.PersistentFlags().Lookup("server")) //nolint // not expect err - viper.BindPFlag("insecure", rootCmd.PersistentFlags().Lookup("insecure")) //nolint // not expect err - viper.BindPFlag("gcloud-cred", rootCmd.PersistentFlags().Lookup("insecure")) //nolint // not expect err + viper.BindPFlag("server", rootCmd.PersistentFlags().Lookup("server")) //nolint // not expect err + viper.BindPFlag("insecure", rootCmd.PersistentFlags().Lookup("insecure")) //nolint // not expect err rootCmd.AddCommand(tokenCmd) } diff --git a/pkg/config/cli_config.go b/pkg/config/cli_config.go index 77fe0ce6..58772b32 100644 --- a/pkg/config/cli_config.go +++ b/pkg/config/cli_config.go @@ -40,9 +40,6 @@ type CLIConfig struct { type CLIAuthentication struct { // Insecure indiates whether to use insecured connection to the JVS server. Insecure bool `yaml:"insecure,omitempty"` - - // GCloud indicates whether to use gcloud idtoken for authentication. - GCloud bool `yaml:"use_gcloud,omitempty"` } // Validate checks if the config is valid. @@ -54,10 +51,6 @@ func (cfg *CLIConfig) Validate() error { err = multierror.Append(err, fmt.Errorf("missing JVS server address")) } - if cfg.Authentication.Insecure && cfg.Authentication.GCloud { - err = multierror.Append(err, fmt.Errorf("only one authentication method can be used")) - } - return err.ErrorOrNil() } From b5d9baaa3b29170f49c4e20f9454d10e606a138b Mon Sep 17 00:00:00 2001 From: Chen Shou Date: Fri, 17 Jun 2022 17:28:48 +0000 Subject: [PATCH 06/11] golint --- pkg/idtoken/idtoken.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/idtoken/idtoken.go b/pkg/idtoken/idtoken.go index 3d227be1..9a84552c 100644 --- a/pkg/idtoken/idtoken.go +++ b/pkg/idtoken/idtoken.go @@ -33,8 +33,8 @@ const ( // These configs are gcloud configs: // https://github.com/twistedpair/google-cloud-sdk/blob/master/google-cloud-sdk/lib/googlecloudsdk/core/config.py CloudSDKClientID = "32555940559.apps.googleusercontent.com" - CloudSDKClientNotSoSecret = "ZmssLNjJy2998hD4CTg2ejr2" - GoogleOAuthTokenURL = "https://oauth2.googleapis.com/token" + CloudSDKClientNotSoSecret = "ZmssLNjJy2998hD4CTg2ejr2" //nolint // gcloud not so secret + GoogleOAuthTokenURL = "https://oauth2.googleapis.com/token" //nolint // false positive not a secret ) // Config is the config to generate id tokens. From e8dea4907edb5289d2fffdd552525bb3a0d224a2 Mon Sep 17 00:00:00 2001 From: Chen Shou Date: Fri, 17 Jun 2022 19:42:20 +0000 Subject: [PATCH 07/11] temp --- pkg/cli/token.go | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/pkg/cli/token.go b/pkg/cli/token.go index ae96113f..c5d3b740 100644 --- a/pkg/cli/token.go +++ b/pkg/cli/token.go @@ -22,13 +22,13 @@ import ( "time" "github.com/spf13/cobra" - "google.golang.org/api/idtoken" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/credentials/oauth" jvsapis "github.com/abcxyz/jvs/apis/v0" + "github.com/abcxyz/jvs/pkg/idtoken" ) var ( @@ -83,13 +83,10 @@ func callOpt(ctx context.Context) (grpc.CallOption, error) { return nil, nil } - ts, err := idtoken.NewTokenSource(ctx, cfg.Server) - if err != nil { - return nil, fmt.Errorf("failed idtoken.NewTokenSource: %w", err) - } + ts, err := idtoken.FromDefaultCredentials(ctx) token, err := ts.Token() if err != nil { - return nil, fmt.Errorf("failed to generate id token: %w", err) + return nil, err } return grpc.PerRPCCredentials(oauth.NewOauthAccess(token)), nil } From 0e52a684f8f46838c9480a4b570f1e539fa61d44 Mon Sep 17 00:00:00 2001 From: Chen Shou Date: Fri, 17 Jun 2022 19:51:46 +0000 Subject: [PATCH 08/11] fixes --- pkg/idtoken/idtoken.go | 93 +++++-------------------------------- pkg/idtoken/idtoken_test.go | 4 +- 2 files changed, 14 insertions(+), 83 deletions(-) diff --git a/pkg/idtoken/idtoken.go b/pkg/idtoken/idtoken.go index 9a84552c..3ced69af 100644 --- a/pkg/idtoken/idtoken.go +++ b/pkg/idtoken/idtoken.go @@ -17,44 +17,13 @@ package idtoken import ( "context" - "encoding/json" "fmt" - "io" - "net/http" - "net/url" - "strings" - "time" "golang.org/x/oauth2" "golang.org/x/oauth2/google" ) -const ( - // These configs are gcloud configs: - // https://github.com/twistedpair/google-cloud-sdk/blob/master/google-cloud-sdk/lib/googlecloudsdk/core/config.py - CloudSDKClientID = "32555940559.apps.googleusercontent.com" - CloudSDKClientNotSoSecret = "ZmssLNjJy2998hD4CTg2ejr2" //nolint // gcloud not so secret - GoogleOAuthTokenURL = "https://oauth2.googleapis.com/token" //nolint // false positive not a secret -) - -// Config is the config to generate id tokens. -type Config struct { - ClientID string - ClientSecret string - TokenURL string - Audience string -} - -// DefaultGoogleConfig is the default config to generate id tokens. -// It uses the same client config as gcloud. -var DefaultGoogleConfig = &Config{ - ClientID: CloudSDKClientID, - ClientSecret: CloudSDKClientNotSoSecret, - TokenURL: GoogleOAuthTokenURL, - Audience: CloudSDKClientID, -} - -// FromDefaultCredentials creates a token source with the application default credentials. +// FromDefaultCredentials creates a token source with the application default credentials (ADC). // https://developers.google.com/accounts/docs/application-default-credentials // It only works when the application default credentials is of an end user. // Typically it's done with `gcloud auth application-default login`. @@ -65,67 +34,29 @@ func FromDefaultCredentials(ctx context.Context) (oauth2.TokenSource, error) { } return oauth2.ReuseTokenSource(nil, &tokenSource{ - refreshTokenSource: ts, - cfg: DefaultGoogleConfig, + tokenSource: ts, }), nil } type tokenSource struct { - refreshTokenSource oauth2.TokenSource - cfg *Config + tokenSource oauth2.TokenSource } -// Given a refresh token, generate an id token. -// For GCP, the client id and the audience must be in the same project. -// -// With FromDefaultCredentials, we reuse the refresh token from application default credentials. -// It uses the gcloud client id. -// -// TODO: Should our CLI support standalone 3-legged OAuth flow w/o relying on gcloud? -// -// For a full flow, reference: https://cloud.google.com/iap/docs/authentication-howto#authenticating_from_a_desktop_app +// Token extracts the id_token field from ADC from a default token source and +// puts the value into the AccessToken field. func (ts *tokenSource) Token() (*oauth2.Token, error) { - rt, err := ts.refreshTokenSource.Token() + token, err := ts.tokenSource.Token() if err != nil { - return nil, fmt.Errorf("failed to get refresh token: %w", err) - } - - v := url.Values{ - "grant_type": {"refresh_token"}, - "refresh_token": {rt.RefreshToken}, - "client_id": {ts.cfg.ClientID}, - "client_secret": {ts.cfg.ClientSecret}, - "audience": {ts.cfg.Audience}, - } - - // Use the refresh token to exchange an id token. - resp, err := http.DefaultClient.Post(ts.cfg.TokenURL, "application/x-www-form-urlencoded", strings.NewReader(v.Encode())) - if err != nil { - return nil, fmt.Errorf("failed to refresh token: %w", err) - } - - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to refresh token: %w", err) - } - - // tokenRes is the JSON response body. - // Interestingly, the actual id token is in its own field, but an oauth2.Token - // only has an AccessToken field. As a result, we need convert it to an oauth2.Token. - var tokenRes struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - IDToken string `json:"id_token"` - ExpiresIn int64 `json:"expires_in"` // relative seconds from now + return nil, err } - if err := json.Unmarshal(b, &tokenRes); err != nil { - return nil, fmt.Errorf("failed to refresh token: %w", err) + idToken, ok := token.Extra("id_token").(string) + if !ok { + return nil, fmt.Errorf("missing id_token") } return &oauth2.Token{ - AccessToken: tokenRes.IDToken, - TokenType: tokenRes.TokenType, - Expiry: time.Now().Add(time.Duration(tokenRes.ExpiresIn) * time.Second), + AccessToken: idToken, + Expiry: token.Expiry, }, nil } diff --git a/pkg/idtoken/idtoken_test.go b/pkg/idtoken/idtoken_test.go index 965816db..41e88bdf 100644 --- a/pkg/idtoken/idtoken_test.go +++ b/pkg/idtoken/idtoken_test.go @@ -32,12 +32,12 @@ func TestFromDefaultCredentials(t *testing.T) { ts, err := FromDefaultCredentials(context.Background()) if err != nil { - t.Errorf("failed to get ID token source: %v", err) + t.Fatalf("failed to get ID token source: %v", err) } tk, err := ts.Token() if err != nil { - t.Errorf("failed to get ID token: %v", err) + t.Fatalf("failed to get ID token: %v", err) } if _, err := jwt.Parse([]byte(tk.AccessToken), jwt.WithVerify(false)); err != nil { From ccc32c22127460901272fbfe49144e1354fcbdf8 Mon Sep 17 00:00:00 2001 From: Chen Shou Date: Fri, 17 Jun 2022 20:11:39 +0000 Subject: [PATCH 09/11] add more unit test --- pkg/idtoken/idtoken_test.go | 72 +++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/pkg/idtoken/idtoken_test.go b/pkg/idtoken/idtoken_test.go index 41e88bdf..4dfbd011 100644 --- a/pkg/idtoken/idtoken_test.go +++ b/pkg/idtoken/idtoken_test.go @@ -16,16 +16,88 @@ package idtoken import ( "context" + "fmt" + "net/url" "os" "testing" + "time" + "github.com/abcxyz/pkg/testutil" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/lestrrat-go/jwx/v2/jwt" + "golang.org/x/oauth2" ) +type fakeDefaultTokenSource struct { + expiry time.Time + idToken string + returnErr error +} + +func (ts *fakeDefaultTokenSource) Token() (*oauth2.Token, error) { + if ts.returnErr != nil { + return nil, ts.returnErr + } + + token := &oauth2.Token{ + Expiry: ts.expiry, + } + + return token.WithExtra(url.Values{ + "id_token": {ts.idToken}, + }), nil +} + +func TestIDTokenFromDefaultTokenSource(t *testing.T) { + t.Parallel() + + now := time.Now() + + tests := []struct { + name string + ts *fakeDefaultTokenSource + wantToken *oauth2.Token + wantErr string + }{{ + name: "success", + ts: &fakeDefaultTokenSource{ + expiry: now, + idToken: "id-token", + }, + wantToken: &oauth2.Token{ + AccessToken: "id-token", + Expiry: now, + }, + }, { + name: "error", + ts: &fakeDefaultTokenSource{ + expiry: now, + returnErr: fmt.Errorf("token err"), + }, + wantErr: "token err", + }} + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + ts := &tokenSource{tokenSource: tc.ts} + gotToken, err := ts.Token() + if diff := testutil.DiffErrString(err, tc.wantErr); diff != "" { + t.Errorf("unexpected err: %s", diff) + } + if diff := cmp.Diff(tc.wantToken, gotToken, cmpopts.IgnoreUnexported(oauth2.Token{})); diff != "" { + t.Errorf("ID token (-want,+got):\n%s", diff) + } + }) + } +} + // Log in as an end user with gcloud. // `gcloud auth application-default login` // Set env var MANUAL_TEST=true to run the test. func TestFromDefaultCredentials(t *testing.T) { + t.Parallel() if os.Getenv("MANUAL_TEST") == "" { t.Skip("Skip manual test; set env var MANUAL_TEST to enable") } From a2946d663fdcf0703ff07a1ce00f30a1150797c0 Mon Sep 17 00:00:00 2001 From: Chen Shou Date: Fri, 17 Jun 2022 22:06:48 +0000 Subject: [PATCH 10/11] temp --- pkg/cli/token.go | 34 ++++++++++++-- pkg/cli/token_test.go | 106 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 133 insertions(+), 7 deletions(-) diff --git a/pkg/cli/token.go b/pkg/cli/token.go index c5d3b740..12a48540 100644 --- a/pkg/cli/token.go +++ b/pkg/cli/token.go @@ -26,6 +26,7 @@ import ( "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/credentials/oauth" + "google.golang.org/protobuf/types/known/durationpb" jvsapis "github.com/abcxyz/jvs/apis/v0" "github.com/abcxyz/jvs/pkg/idtoken" @@ -45,13 +46,36 @@ var tokenCmd = &cobra.Command{ } func runTokenCmd(cmd *cobra.Command, args []string) error { - conn, err := grpc.Dial(cfg.Server) + ctx := context.Background() + dialOpt, err := dialOpt() + if err != nil { + return err + } + callOpt, err := callOpt(ctx) + if err != nil { + return err + } + + conn, err := grpc.Dial(cfg.Server, dialOpt) if err != nil { return fmt.Errorf("failed to connect to JVS service: %w", err) } - jvsapis.NewJVSServiceClient(conn) + jvsclient := jvsapis.NewJVSServiceClient(conn) - return fmt.Errorf("not implemented") + req := &jvsapis.CreateJustificationRequest{ + Justifications: []*jvsapis.Justification{{ + Category: "explanation", + Value: tokenExplanation, + }}, + Ttl: durationpb.New(ttl), + } + resp, err := jvsclient.CreateJustification(ctx, req, callOpt) + if err != nil { + return err + } + + _, err = cmd.OutOrStdout().Write([]byte(resp.Token)) + return err } func init() { @@ -84,6 +108,10 @@ func callOpt(ctx context.Context) (grpc.CallOption, error) { } ts, err := idtoken.FromDefaultCredentials(ctx) + if err != nil { + return nil, err + } + token, err := ts.Token() if err != nil { return nil, err diff --git a/pkg/cli/token_test.go b/pkg/cli/token_test.go index c01b5ba8..8fb023f1 100644 --- a/pkg/cli/token_test.go +++ b/pkg/cli/token_test.go @@ -15,15 +15,113 @@ package cli import ( + "context" + "fmt" + "net" + "strings" "testing" + "time" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + jvsapis "github.com/abcxyz/jvs/apis/v0" + "github.com/abcxyz/jvs/pkg/config" "github.com/abcxyz/pkg/testutil" + "github.com/spf13/cobra" ) +type fakeJVS struct { + jvsapis.UnimplementedJVSServiceServer + returnErr error +} + +func (j *fakeJVS) CreateJustification(_ context.Context, req *jvsapis.CreateJustificationRequest) (*jvsapis.CreateJustificationResponse, error) { + if j.returnErr != nil { + return nil, j.returnErr + } + + if req.Justifications[0].Category != "explanation" { + return nil, fmt.Errorf("unexpected category: %q", req.Justifications[0].Category) + } + + return &jvsapis.CreateJustificationResponse{ + Token: fmt.Sprintf("tokenized(%s);ttl=%v", req.Justifications[0].Value, req.Ttl.AsDuration()), + }, nil +} + func TestRunTokenCmd(t *testing.T) { - err := runTokenCmd(nil, nil) - wantErrStr := "not implemented" - if diff := testutil.DiffErrString(err, wantErrStr); diff != "" { - t.Errorf("unexpected error: %s", diff) + t.Parallel() + + tests := []struct { + name string + jvs *fakeJVS + wantErr string + }{{ + name: "success", + jvs: &fakeJVS{}, + }, { + name: "error", + jvs: &fakeJVS{returnErr: fmt.Errorf("server err")}, + wantErr: "server err", + }} + + for _, tc := range tests { + // Cannot parallel because the global CLI config. + t.Run(tc.name, func(t *testing.T) { + server, _ := testFakeGRPCServer(t, func(s *grpc.Server) { jvsapis.RegisterJVSServiceServer(s, tc.jvs) }) + + // These are global flags. + cfg = &config.CLIConfig{ + Server: server, + Authentication: &config.CLIAuthentication{ + Insecure: true, + }, + } + ttl = time.Minute + tokenExplanation = "i-have-reason" + + buf := &strings.Builder{} + cmd := &cobra.Command{} + cmd.SetOut(buf) + + err := runTokenCmd(cmd, nil) + if diff := testutil.DiffErrString(err, tc.wantErr); diff != "" { + t.Errorf("unexpected err: %s", diff) + } + + wantToken := fmt.Sprintf("tokenized(i-have-reason);ttl=%v", ttl) + if gotToken := buf.String(); gotToken != wantToken { + t.Errorf("justification token got=%q, want=%q", gotToken, wantToken) + } + }) + } +} + +// Copied over from Lumberjack. TODO: share it in pkg. +func testFakeGRPCServer(tb testing.TB, registerFunc func(*grpc.Server)) (string, *grpc.ClientConn) { + tb.Helper() + + s := grpc.NewServer() + tb.Cleanup(func() { s.GracefulStop() }) + + registerFunc(s) + + lis, err := net.Listen("tcp", "localhost:0") + if err != nil { + tb.Fatalf("net.Listen(tcp, localhost:0) failed: %v", err) + } + + go func() { + if err := s.Serve(lis); err != nil { + tb.Logf("net.Listen(tcp, localhost:0) serve failed: %v", err) + } + }() + + addr := lis.Addr().String() + conn, err := grpc.Dial(addr, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + tb.Fatalf("failed to dail %q: %s", addr, err) } + return addr, conn } From 3dacb752c15183fba4935d9567ca2906652a1d10 Mon Sep 17 00:00:00 2001 From: Chen Shou Date: Fri, 17 Jun 2022 22:17:40 +0000 Subject: [PATCH 11/11] use idtoken --- pkg/cli/root_test.go | 5 +++-- pkg/cli/token.go | 20 +++++++++++--------- pkg/cli/token_test.go | 28 ++++++++++++++++------------ 3 files changed, 30 insertions(+), 23 deletions(-) diff --git a/pkg/cli/root_test.go b/pkg/cli/root_test.go index c5e2c65a..ab1e5c3f 100644 --- a/pkg/cli/root_test.go +++ b/pkg/cli/root_test.go @@ -40,8 +40,9 @@ func TestInitCfg(t *testing.T) { initCfg() wantCfg := &config.CLIConfig{ - Version: 1, - Server: "https://example.com", + Version: 1, + Server: "https://example.com", + Authentication: &config.CLIAuthentication{}, } if diff := cmp.Diff(wantCfg, cfg); diff != "" { t.Errorf("CLI config loaded (-want,+got):\n%s", diff) diff --git a/pkg/cli/token.go b/pkg/cli/token.go index 12a48540..46436427 100644 --- a/pkg/cli/token.go +++ b/pkg/cli/token.go @@ -47,16 +47,18 @@ var tokenCmd = &cobra.Command{ func runTokenCmd(cmd *cobra.Command, args []string) error { ctx := context.Background() - dialOpt, err := dialOpt() + dialOpts, err := dialOpts() if err != nil { return err } - callOpt, err := callOpt(ctx) + callOpts, err := callOpts(ctx) if err != nil { return err } - conn, err := grpc.Dial(cfg.Server, dialOpt) + // TODO(#69): Generate breakglass token w/o JVS server. + + conn, err := grpc.Dial(cfg.Server, dialOpts...) if err != nil { return fmt.Errorf("failed to connect to JVS service: %w", err) } @@ -69,7 +71,7 @@ func runTokenCmd(cmd *cobra.Command, args []string) error { }}, Ttl: durationpb.New(ttl), } - resp, err := jvsclient.CreateJustification(ctx, req, callOpt) + resp, err := jvsclient.CreateJustification(ctx, req, callOpts...) if err != nil { return err } @@ -85,9 +87,9 @@ func init() { tokenCmd.Flags().DurationVar(&ttl, "ttl", time.Hour, "The token time-to-live duration") } -func dialOpt() (grpc.DialOption, error) { +func dialOpts() ([]grpc.DialOption, error) { if cfg.Authentication.Insecure { - return grpc.WithTransportCredentials(insecure.NewCredentials()), nil + return []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}, nil } // The default. @@ -99,10 +101,10 @@ func dialOpt() (grpc.DialOption, error) { cred := credentials.NewTLS(&tls.Config{ RootCAs: systemRoots, }) - return grpc.WithTransportCredentials(cred), nil + return []grpc.DialOption{grpc.WithTransportCredentials(cred)}, nil } -func callOpt(ctx context.Context) (grpc.CallOption, error) { +func callOpts(ctx context.Context) ([]grpc.CallOption, error) { if cfg.Authentication.Insecure { return nil, nil } @@ -116,5 +118,5 @@ func callOpt(ctx context.Context) (grpc.CallOption, error) { if err != nil { return nil, err } - return grpc.PerRPCCredentials(oauth.NewOauthAccess(token)), nil + return []grpc.CallOption{grpc.PerRPCCredentials(oauth.NewOauthAccess(token))}, nil } diff --git a/pkg/cli/token_test.go b/pkg/cli/token_test.go index 8fb023f1..a2f61c19 100644 --- a/pkg/cli/token_test.go +++ b/pkg/cli/token_test.go @@ -54,16 +54,21 @@ func TestRunTokenCmd(t *testing.T) { t.Parallel() tests := []struct { - name string - jvs *fakeJVS - wantErr string + name string + jvs *fakeJVS + explanation string + wantToken string + wantErr string }{{ - name: "success", - jvs: &fakeJVS{}, + name: "success", + jvs: &fakeJVS{}, + explanation: "i-have-reason", + wantToken: fmt.Sprintf("tokenized(i-have-reason);ttl=%v", time.Minute), }, { - name: "error", - jvs: &fakeJVS{returnErr: fmt.Errorf("server err")}, - wantErr: "server err", + name: "error", + jvs: &fakeJVS{returnErr: fmt.Errorf("server err")}, + explanation: "i-have-reason", + wantErr: "server err", }} for _, tc := range tests { @@ -78,8 +83,8 @@ func TestRunTokenCmd(t *testing.T) { Insecure: true, }, } + tokenExplanation = tc.explanation ttl = time.Minute - tokenExplanation = "i-have-reason" buf := &strings.Builder{} cmd := &cobra.Command{} @@ -90,9 +95,8 @@ func TestRunTokenCmd(t *testing.T) { t.Errorf("unexpected err: %s", diff) } - wantToken := fmt.Sprintf("tokenized(i-have-reason);ttl=%v", ttl) - if gotToken := buf.String(); gotToken != wantToken { - t.Errorf("justification token got=%q, want=%q", gotToken, wantToken) + if gotToken := buf.String(); gotToken != tc.wantToken { + t.Errorf("justification token got=%q, want=%q", gotToken, tc.wantToken) } }) }