Skip to content

Commit 08d827c

Browse files
committed
feat: add support for gcloud auth (#1166)
1 parent f1ae7ea commit 08d827c

File tree

7 files changed

+280
-22
lines changed

7 files changed

+280
-22
lines changed

cmd/root.go

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,10 @@ import (
2929

3030
"cloud.google.com/go/cloudsqlconn"
3131
"github.com/GoogleCloudPlatform/cloudsql-proxy/v2/cloudsql"
32+
"github.com/GoogleCloudPlatform/cloudsql-proxy/v2/internal/gcloud"
3233
"github.com/GoogleCloudPlatform/cloudsql-proxy/v2/internal/proxy"
3334
"github.com/spf13/cobra"
35+
"golang.org/x/oauth2"
3436
)
3537

3638
var (
@@ -110,6 +112,8 @@ any client SSL certificates.`,
110112
"Bearer token used for authorization.")
111113
cmd.PersistentFlags().StringVarP(&c.conf.CredentialsFile, "credentials-file", "c", "",
112114
"Path to a service account key to use for authentication.")
115+
cmd.PersistentFlags().BoolVarP(&c.conf.GcloudAuth, "gcloud-auth", "g", false,
116+
"Use gcloud's user configuration to retrieve a token for authentication.")
113117

114118
// Global and per instance flags
115119
cmd.PersistentFlags().StringVarP(&c.conf.Addr, "address", "a", "127.0.0.1",
@@ -131,19 +135,41 @@ func parseConfig(cmd *cobra.Command, conf *proxy.Config, args []string) error {
131135
return newBadCommandError(fmt.Sprintf("not a valid IP address: %q", conf.Addr))
132136
}
133137

134-
// If both token and credentials file were set, error.
138+
// If more than one auth method is set, error.
135139
if conf.Token != "" && conf.CredentialsFile != "" {
136140
return newBadCommandError("Cannot specify --token and --credentials-file flags at the same time")
137141
}
138-
142+
if conf.Token != "" && conf.GcloudAuth {
143+
return newBadCommandError("Cannot specify --token and --gcloud-auth flags at the same time")
144+
}
145+
if conf.CredentialsFile != "" && conf.GcloudAuth {
146+
return newBadCommandError("Cannot specify --credentials-file and --gcloud-auth flags at the same time")
147+
}
148+
opts := []cloudsqlconn.Option{
149+
cloudsqlconn.WithUserAgent(userAgent),
150+
}
139151
switch {
140152
case conf.Token != "":
141153
cmd.Printf("Authorizing with the -token flag\n")
154+
opts = append(opts, cloudsqlconn.WithTokenSource(
155+
oauth2.StaticTokenSource(&oauth2.Token{AccessToken: conf.Token}),
156+
))
142157
case conf.CredentialsFile != "":
143158
cmd.Printf("Authorizing with the credentials file at %q\n", conf.CredentialsFile)
159+
opts = append(opts, cloudsqlconn.WithCredentialsFile(
160+
conf.CredentialsFile,
161+
))
162+
case conf.GcloudAuth:
163+
cmd.Println("Authorizing with gcloud user credentials")
164+
ts, err := gcloud.TokenSource()
165+
if err != nil {
166+
return err
167+
}
168+
opts = append(opts, cloudsqlconn.WithTokenSource(ts))
144169
default:
145-
cmd.Printf("Authorizing with Application Default Credentials")
170+
cmd.Println("Authorizing with Application Default Credentials")
146171
}
172+
conf.DialerOpts = opts
147173

148174
var ics []proxy.InstanceConnConfig
149175
for _, a := range args {
@@ -227,9 +253,8 @@ func runSignalWrapper(cmd *Command) error {
227253
// Otherwise, initialize a new one.
228254
d := cmd.conf.Dialer
229255
if d == nil {
230-
opts := append(cmd.conf.DialerOpts(), cloudsqlconn.WithUserAgent(userAgent))
231256
var err error
232-
d, err = cloudsqlconn.NewDialer(ctx, opts...)
257+
d, err = cloudsqlconn.NewDialer(ctx, cmd.conf.DialerOpts...)
233258
if err != nil {
234259
shutdownCh <- fmt.Errorf("error initializing dialer: %v", err)
235260
return

cmd/root_test.go

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,16 @@ import (
2424

2525
"cloud.google.com/go/cloudsqlconn"
2626
"github.com/GoogleCloudPlatform/cloudsql-proxy/v2/internal/proxy"
27+
"github.com/GoogleCloudPlatform/cloudsql-proxy/v2/internal/testutil"
2728
"github.com/google/go-cmp/cmp"
2829
"github.com/google/go-cmp/cmp/cmpopts"
2930
"github.com/spf13/cobra"
3031
)
3132

3233
func TestNewCommandArguments(t *testing.T) {
34+
cleanup := testutil.ConfigureGcloud(t)
35+
defer cleanup()
36+
3337
withDefaults := func(c *proxy.Config) *proxy.Config {
3438
if c.Addr == "" {
3539
c.Addr = "127.0.0.1"
@@ -133,6 +137,20 @@ func TestNewCommandArguments(t *testing.T) {
133137
CredentialsFile: "/path/to/file",
134138
}),
135139
},
140+
{
141+
desc: "using the gcloud auth flag",
142+
args: []string{"--gcloud-auth", "proj:region:inst"},
143+
want: withDefaults(&proxy.Config{
144+
GcloudAuth: true,
145+
}),
146+
},
147+
{
148+
desc: "using the (short) gcloud auth flag",
149+
args: []string{"-g", "proj:region:inst"},
150+
want: withDefaults(&proxy.Config{
151+
GcloudAuth: true,
152+
}),
153+
},
136154
}
137155

138156
for _, tc := range tcs {
@@ -152,7 +170,8 @@ func TestNewCommandArguments(t *testing.T) {
152170
t.Fatalf("want error = nil, got = %v", err)
153171
}
154172

155-
if got := c.conf; !cmp.Equal(tc.want, got, cmpopts.IgnoreUnexported(proxy.Config{})) {
173+
opts := cmpopts.IgnoreFields(proxy.Config{}, "DialerOpts")
174+
if got := c.conf; !cmp.Equal(tc.want, got, opts) {
156175
t.Fatalf("want = %#v\ngot = %#v\ndiff = %v", tc.want, got, cmp.Diff(tc.want, got))
157176
}
158177
})
@@ -201,11 +220,23 @@ func TestNewCommandWithErrors(t *testing.T) {
201220
args: []string{"proj:region:inst?port=hi"},
202221
},
203222
{
204-
desc: "when both token and credentials file is set",
223+
desc: "when both token and credentials file are set",
205224
args: []string{
206225
"--token", "my-token",
207226
"--credentials-file", "/path/to/file", "proj:region:inst"},
208227
},
228+
{
229+
desc: "when both token and gcloud auth are set",
230+
args: []string{
231+
"--token", "my-token",
232+
"--gcloud-auth", "proj:region:inst"},
233+
},
234+
{
235+
desc: "when both gcloud auth and credentials file are set",
236+
args: []string{
237+
"--gcloud-auth",
238+
"--credential-file", "/path/to/file", "proj:region:inst"},
239+
},
209240
}
210241

211242
for _, tc := range tcs {

internal/gcloud/gcloud.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Copyright 2022 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package gcloud
16+
17+
import (
18+
"bytes"
19+
"encoding/json"
20+
"fmt"
21+
"runtime"
22+
"time"
23+
24+
"golang.org/x/oauth2"
25+
exec "golang.org/x/sys/execabs"
26+
)
27+
28+
// config represents the credentials returned by `gcloud config config-helper`.
29+
type config struct {
30+
Credential struct {
31+
AccessToken string `json:"access_token"`
32+
TokenExpiry time.Time `json:"token_expiry"`
33+
}
34+
}
35+
36+
func (c *config) Token() *oauth2.Token {
37+
return &oauth2.Token{
38+
AccessToken: c.Credential.AccessToken,
39+
Expiry: c.Credential.TokenExpiry,
40+
}
41+
}
42+
43+
// Path returns the absolute path to the gcloud command. If the command is not
44+
// found it returns an error.
45+
func Path() (string, error) {
46+
g := "gcloud"
47+
if runtime.GOOS == "windows" {
48+
g = g + ".cmd"
49+
}
50+
return exec.LookPath(g)
51+
}
52+
53+
// configHelper implements oauth2.TokenSource via the `gcloud config config-helper` command.
54+
type configHelper struct{}
55+
56+
// Token helps gcloudTokenSource implement oauth2.TokenSource.
57+
func (configHelper) Token() (*oauth2.Token, error) {
58+
gcloudCmd, err := Path()
59+
if err != nil {
60+
return nil, err
61+
}
62+
buf, errbuf := new(bytes.Buffer), new(bytes.Buffer)
63+
cmd := exec.Command(gcloudCmd, "--format", "json", "config", "config-helper", "--min-expiry", "1h")
64+
cmd.Stdout = buf
65+
cmd.Stderr = errbuf
66+
67+
if err := cmd.Run(); err != nil {
68+
err = fmt.Errorf("error reading config: %v; stderr was:\n%v", err, errbuf)
69+
return nil, err
70+
}
71+
72+
c := &config{}
73+
if err := json.Unmarshal(buf.Bytes(), c); err != nil {
74+
return nil, err
75+
}
76+
return c.Token(), nil
77+
}
78+
79+
// TokenSource returns an oauth2.TokenSource backed by the gcloud CLI.
80+
func TokenSource() (oauth2.TokenSource, error) {
81+
h := configHelper{}
82+
tok, err := h.Token()
83+
if err != nil {
84+
return nil, err
85+
}
86+
return oauth2.ReuseTokenSource(tok, h), nil
87+
}

internal/gcloud/gcloud_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright 2022 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package gcloud_test
16+
17+
import (
18+
"testing"
19+
20+
"github.com/GoogleCloudPlatform/cloudsql-proxy/v2/internal/gcloud"
21+
"github.com/GoogleCloudPlatform/cloudsql-proxy/v2/internal/testutil"
22+
)
23+
24+
func TestGcloud(t *testing.T) {
25+
if testing.Short() {
26+
t.Skip("skipping gcloud integration tests")
27+
}
28+
29+
cleanup := testutil.ConfigureGcloud(t)
30+
defer cleanup()
31+
32+
// gcloud is now configured. Try to obtain a token from gcloud config
33+
// helper.
34+
ts, err := gcloud.TokenSource()
35+
if err != nil {
36+
t.Fatalf("failed to get token source: %v", err)
37+
}
38+
39+
_, err = ts.Token()
40+
if err != nil {
41+
t.Fatalf("failed to get token: %v", err)
42+
}
43+
}

internal/proxy/proxy.go

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import (
2626
"cloud.google.com/go/cloudsqlconn"
2727
"github.com/GoogleCloudPlatform/cloudsql-proxy/v2/cloudsql"
2828
"github.com/spf13/cobra"
29-
"golang.org/x/oauth2"
3029
)
3130

3231
// InstanceConnConfig holds the configuration for an individual instance
@@ -48,6 +47,10 @@ type Config struct {
4847
// CredentialsFile is the path to a service account key.
4948
CredentialsFile string
5049

50+
// GcloudAuth set whether to use Gcloud's config helper to retrieve a
51+
// token for authentication.
52+
GcloudAuth bool
53+
5154
// Addr is the address on which to bind all instances.
5255
Addr string
5356

@@ -62,21 +65,10 @@ type Config struct {
6265
// Dialer specifies the dialer to use when connecting to Cloud SQL
6366
// instances.
6467
Dialer cloudsql.Dialer
65-
}
6668

67-
func (c *Config) DialerOpts() []cloudsqlconn.Option {
68-
var opts []cloudsqlconn.Option
69-
switch {
70-
case c.Token != "":
71-
opts = append(opts, cloudsqlconn.WithTokenSource(
72-
oauth2.StaticTokenSource(&oauth2.Token{AccessToken: c.Token}),
73-
))
74-
case c.CredentialsFile != "":
75-
opts = append(opts, cloudsqlconn.WithCredentialsFile(
76-
c.CredentialsFile,
77-
))
78-
}
79-
return opts
69+
// DialerOpts specifies the opts to use when creating a new dialer. This
70+
// value is ignored when a Dialer has been set.
71+
DialerOpts []cloudsqlconn.Option
8072
}
8173

8274
type portConfig struct {

internal/testutil/testutil.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Copyright 2022 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package testutil
16+
17+
import (
18+
"bytes"
19+
"io/ioutil"
20+
"os"
21+
"os/exec"
22+
"testing"
23+
24+
"github.com/GoogleCloudPlatform/cloudsql-proxy/v2/internal/gcloud"
25+
)
26+
27+
// ConfigureGcloud configures gcloud using only GOOGLE_APPLICATION_CREDENTIALS
28+
// and stores the resulting configuration in a temporary directory as set by
29+
// CLOUDSDK_CONFIG, which changes the gcloud config directory from the
30+
// default. We use a temporary directory to avoid trampling on any existing
31+
// gcloud config.
32+
func ConfigureGcloud(t *testing.T) func() {
33+
dir, err := ioutil.TempDir("", "cloudsdk*")
34+
if err != nil {
35+
t.Fatalf("failed to create temp dir: %v", err)
36+
}
37+
os.Setenv("CLOUDSDK_CONFIG", dir)
38+
39+
gcloudCmd, err := gcloud.Path()
40+
if err != nil {
41+
t.Fatal(err)
42+
}
43+
44+
keyFile, ok := os.LookupEnv("GOOGLE_APPLICATION_CREDENTIALS")
45+
if !ok {
46+
t.Fatal("GOOGLE_APPLICATION_CREDENTIALS is not set in the environment")
47+
}
48+
os.Unsetenv("GOOGLE_APPLICATION_CREDENTIALS")
49+
50+
buf := &bytes.Buffer{}
51+
cmd := exec.Command(gcloudCmd, "auth", "activate-service-account", "--key-file", keyFile)
52+
cmd.Stdout = buf
53+
54+
if err := cmd.Run(); err != nil {
55+
t.Fatalf("failed to active service account. err = %v, message = %v", err, buf.String())
56+
}
57+
58+
return func() {
59+
os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", keyFile)
60+
os.Unsetenv("CLOUDSDK_CONFIG")
61+
}
62+
63+
}

0 commit comments

Comments
 (0)