Skip to content

Commit 6a033d8

Browse files
committed
fix: return error with conflicting credential opts
This commit ensures conflicting credential options result in a user-facing error. This commit moves all the dialer config initialization into a single function in options.go. This will help the migration to supporting auth.Credentials and makes the relationship between all the options clear. Previously, there was a mix of option functions setting various fields, possibly returning errors, and the dialer initializer doing additional configuration work. This made it hard to understand how the different credential options related to one another and hid the bug of allowing callers to set conflicting options. In addition, this commit adds end to end tests to verify the credential-related options work as intended. This is a belated port of GoogleCloudPlatform/cloud-sql-go-connector#460. Related to #646
1 parent c42792b commit 6a033d8

4 files changed

Lines changed: 266 additions & 84 deletions

File tree

credentials_test.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Copyright 2025 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+
// https://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 alloydbconn_test
16+
17+
import (
18+
"context"
19+
"fmt"
20+
"net"
21+
"os"
22+
"testing"
23+
"time"
24+
25+
"cloud.google.com/go/alloydbconn"
26+
"github.com/jackc/pgx/v5/pgxpool"
27+
"golang.org/x/oauth2"
28+
"golang.org/x/oauth2/google"
29+
)
30+
31+
// removeAuthEnvVar retrieves an OAuth2 token and a path to a service account key
32+
// and then unsets GOOGLE_APPLICATION_CREDENTIALS. It returns a cleanup function
33+
// that restores the original setup.
34+
func removeAuthEnvVar(t *testing.T) (*oauth2.Token, string, func()) {
35+
ts, err := google.DefaultTokenSource(context.Background(),
36+
"https://www.googleapis.com/auth/cloud-platform",
37+
)
38+
if err != nil {
39+
t.Errorf("failed to resolve token source: %v", err)
40+
}
41+
42+
tok, err := ts.Token()
43+
if err != nil {
44+
t.Errorf("failed to get token: %v", err)
45+
}
46+
path, ok := os.LookupEnv("GOOGLE_APPLICATION_CREDENTIALS")
47+
if !ok {
48+
t.Fatalf("GOOGLE_APPLICATION_CREDENTIALS was not set in the environment")
49+
}
50+
if err := os.Unsetenv("GOOGLE_APPLICATION_CREDENTIALS"); err != nil {
51+
t.Fatalf("failed to unset GOOGLE_APPLICATION_CREDENTIALS")
52+
}
53+
return tok, path, func() {
54+
os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", path)
55+
}
56+
}
57+
58+
func keyfile(t *testing.T) string {
59+
path := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")
60+
if path == "" {
61+
t.Fatal("GOOGLE_APPLICATION_CREDENTIALS not set")
62+
}
63+
creds, err := os.ReadFile(path)
64+
if err != nil {
65+
t.Fatalf("io.ReadAll(): %v", err)
66+
}
67+
return string(creds)
68+
}
69+
70+
func TestPostgresAuthentication(t *testing.T) {
71+
if testing.Short() {
72+
t.Skip("skipping credential integration tests")
73+
}
74+
requireAlloyDBVars(t)
75+
creds := keyfile(t)
76+
77+
tok, path, cleanup := removeAuthEnvVar(t)
78+
defer cleanup()
79+
80+
tcs := []struct {
81+
desc string
82+
opt alloydbconn.Option
83+
}{
84+
{
85+
desc: "with token",
86+
opt: alloydbconn.WithTokenSource(oauth2.StaticTokenSource(tok)),
87+
},
88+
{
89+
desc: "with credentials file",
90+
opt: alloydbconn.WithCredentialsFile(path),
91+
},
92+
{
93+
desc: "with credentials JSON",
94+
opt: alloydbconn.WithCredentialsJSON([]byte(creds)),
95+
},
96+
}
97+
for _, tc := range tcs {
98+
t.Run(tc.desc, func(t *testing.T) {
99+
ctx := context.Background()
100+
101+
d, err := alloydbconn.NewDialer(ctx, tc.opt, alloydbconn.WithIAMAuthN())
102+
if err != nil {
103+
t.Fatalf("failed to init Dialer: %v", err)
104+
}
105+
defer d.Close()
106+
107+
dsn := fmt.Sprintf("user=%s dbname=%s sslmode=disable", alloydbIAMUser, alloydbDB)
108+
config, err := pgxpool.ParseConfig(dsn)
109+
if err != nil {
110+
t.Fatalf("failed to parse pgx config: %v", err)
111+
}
112+
113+
config.ConnConfig.DialFunc = func(ctx context.Context, _ string, _ string) (net.Conn, error) {
114+
return d.Dial(ctx, alloydbInstanceName)
115+
}
116+
117+
pool, err := pgxpool.NewWithConfig(ctx, config)
118+
if err != nil {
119+
t.Fatalf("failed to create pool: %s", err)
120+
}
121+
defer pool.Close()
122+
123+
var now time.Time
124+
err = pool.QueryRow(context.Background(), "SELECT NOW()").Scan(&now)
125+
if err != nil {
126+
t.Fatalf("QueryRow failed: %s", err)
127+
}
128+
t.Log(now)
129+
})
130+
}
131+
}

dialer.go

Lines changed: 4 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,7 @@ import (
3636
"cloud.google.com/go/alloydbconn/internal/alloydb"
3737
"cloud.google.com/go/alloydbconn/internal/tel"
3838
"github.com/google/uuid"
39-
"golang.org/x/net/proxy"
4039
"golang.org/x/oauth2"
41-
"golang.org/x/oauth2/google"
4240
"google.golang.org/api/option"
4341
"google.golang.org/protobuf/proto"
4442

@@ -189,34 +187,9 @@ func (nullLogger) Debugf(context.Context, string, ...any) {}
189187
// RSA keypair is performed. Calls with a WithRSAKeyPair DialOption or after a default
190188
// RSA keypair is generated will be faster.
191189
func NewDialer(ctx context.Context, opts ...Option) (*Dialer, error) {
192-
cfg := &dialerConfig{
193-
refreshTimeout: alloydb.RefreshTimeout,
194-
dialFunc: proxy.Dial,
195-
logger: nullLogger{},
196-
userAgents: []string{userAgent},
197-
}
198-
for _, opt := range opts {
199-
opt(cfg)
200-
if cfg.err != nil {
201-
return nil, cfg.err
202-
}
203-
}
204-
if cfg.disableMetadataExchange && cfg.useIAMAuthN {
205-
return nil, errors.New("incompatible options: WithOptOutOfAdvancedConnection " +
206-
"check cannot be used with WithIAMAuthN")
207-
}
208-
userAgent := strings.Join(cfg.userAgents, " ")
209-
// Add user agent to the end to make sure it's not overridden.
210-
cfg.clientOpts = append(cfg.clientOpts, option.WithUserAgent(userAgent))
211-
212-
// If no token source is configured, use ADC's token source.
213-
ts := cfg.tokenSource
214-
if ts == nil {
215-
var err error
216-
ts, err = google.DefaultTokenSource(ctx, CloudPlatformScope)
217-
if err != nil {
218-
return nil, err
219-
}
190+
cfg, err := newDialerConfig(ctx, opts...)
191+
if err != nil {
192+
return nil, err
220193
}
221194

222195
cOpts := append(cfg.alloydbClientOpts, cfg.clientOpts...)
@@ -261,7 +234,7 @@ func NewDialer(ctx context.Context, opts ...Option) (*Dialer, error) {
261234
metricRecorders: map[alloydb.InstanceURI]telv2.MetricRecorder{},
262235
dialFunc: cfg.dialFunc,
263236
useIAMAuthN: cfg.useIAMAuthN,
264-
iamTokenSource: ts,
237+
iamTokenSource: cfg.tokenSource,
265238
userAgent: userAgent,
266239
buffer: newBuffer(),
267240
}

options.go

Lines changed: 104 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,21 @@ package alloydbconn
1717
import (
1818
"context"
1919
"crypto/rsa"
20+
"errors"
2021
"io"
2122
"net"
2223
"net/http"
2324
"os"
25+
"strings"
2426
"time"
2527

2628
"cloud.google.com/go/alloydbconn/debug"
2729
"cloud.google.com/go/alloydbconn/errtype"
2830
"cloud.google.com/go/alloydbconn/internal/alloydb"
31+
"golang.org/x/net/proxy"
2932
"golang.org/x/oauth2"
3033
"golang.org/x/oauth2/google"
31-
apiopt "google.golang.org/api/option"
34+
"google.golang.org/api/option"
3235
)
3336

3437
// CloudPlatformScope is the default OAuth2 scope set on the API client.
@@ -37,23 +40,109 @@ const CloudPlatformScope = "https://www.googleapis.com/auth/cloud-platform"
3740
// An Option is an option for configuring a Dialer.
3841
type Option func(d *dialerConfig)
3942

43+
func newDialerConfig(ctx context.Context, opts ...Option) (*dialerConfig, error) {
44+
d := &dialerConfig{
45+
refreshTimeout: alloydb.RefreshTimeout,
46+
dialFunc: proxy.Dial,
47+
logger: nullLogger{},
48+
userAgents: []string{userAgent},
49+
}
50+
for _, opt := range opts {
51+
opt(d)
52+
}
53+
54+
incompatibleAuthOpts := d.disableMetadataExchange && d.useIAMAuthN
55+
if incompatibleAuthOpts {
56+
return nil, errors.New("incompatible options: WithOptOutOfAdvancedConnectionCheck " +
57+
"cannot be used with WithIAMAuthN")
58+
}
59+
incompatibleAuthOpts = d.credentialsFile != "" && d.credentialsJSON != nil
60+
if incompatibleAuthOpts {
61+
return nil, errors.New("incompatible options: WithCredentialsFile " +
62+
"cannot be used with WithCredentialsJSON")
63+
}
64+
incompatibleAuthOpts = d.credentialsFile != "" && d.tokenSource != nil
65+
if incompatibleAuthOpts {
66+
return nil, errors.New("incompatible options: WithCredentialsFile " +
67+
"cannot be used with WithTokenSource")
68+
}
69+
incompatibleAuthOpts = d.credentialsJSON != nil && d.tokenSource != nil
70+
if incompatibleAuthOpts {
71+
return nil, errors.New("incompatible options: WithCredentialsJSON " +
72+
"cannot be used with WithTokenSource")
73+
}
74+
75+
switch {
76+
case d.credentialsFile != "":
77+
b, err := os.ReadFile(d.credentialsFile)
78+
if err != nil {
79+
return nil, errtype.NewConfigError(err.Error(), "n/a")
80+
}
81+
// TODO: Use AlloyDB-specfic scope
82+
c, err := google.CredentialsFromJSON(context.Background(), b, CloudPlatformScope)
83+
if err != nil {
84+
return nil, errtype.NewConfigError(err.Error(), "n/a")
85+
}
86+
d.tokenSource = c.TokenSource
87+
d.clientOpts = append(d.clientOpts, option.WithCredentials(c))
88+
case d.credentialsJSON != nil:
89+
// TODO: Use AlloyDB-specfic scope
90+
c, err := google.CredentialsFromJSON(context.Background(), d.credentialsJSON, CloudPlatformScope)
91+
if err != nil {
92+
return nil, errtype.NewConfigError(err.Error(), "n/a")
93+
}
94+
d.tokenSource = c.TokenSource
95+
d.clientOpts = append(d.clientOpts, option.WithCredentials(c))
96+
case d.tokenSource != nil:
97+
d.clientOpts = append(d.clientOpts, option.WithTokenSource(d.tokenSource))
98+
default:
99+
// If a credentials file, credentials JSON, or a token soruce was not provided,
100+
// default to Application Default Credentials.
101+
var err error
102+
ts, err := google.DefaultTokenSource(ctx, CloudPlatformScope)
103+
if err != nil {
104+
return nil, err
105+
}
106+
d.tokenSource = ts
107+
}
108+
109+
if d.httpClient != nil {
110+
d.clientOpts = append(d.clientOpts, option.WithHTTPClient(d.httpClient))
111+
}
112+
113+
if d.adminAPIEndpoint != "" {
114+
d.alloydbClientOpts = append(d.alloydbClientOpts, option.WithEndpoint(d.adminAPIEndpoint))
115+
}
116+
117+
userAgent := strings.Join(d.userAgents, " ")
118+
// Add user agent to the end to make sure it's not overridden.
119+
d.clientOpts = append(d.clientOpts, option.WithUserAgent(userAgent))
120+
121+
return d, nil
122+
}
123+
40124
type dialerConfig struct {
41125
rsaKey *rsa.PrivateKey
42126
// alloydbClientOpts are options to configure only the AlloyDB Rest API
43127
// client. Configuration that should apply to all Google Cloud API clients
44128
// should be included in clientOpts.
45-
alloydbClientOpts []apiopt.ClientOption
129+
alloydbClientOpts []option.ClientOption
46130
// clientOpts are options to configure any Google Cloud API client. They
47131
// should not include any AlloyDB-specific configuration.
48-
clientOpts []apiopt.ClientOption
49-
dialOpts []DialOption
50-
dialFunc func(ctx context.Context, network, addr string) (net.Conn, error)
51-
refreshTimeout time.Duration
52-
tokenSource oauth2.TokenSource
53-
userAgents []string
54-
useIAMAuthN bool
55-
logger debug.ContextLogger
56-
lazyRefresh bool
132+
clientOpts []option.ClientOption
133+
dialOpts []DialOption
134+
dialFunc func(ctx context.Context, network, addr string) (net.Conn, error)
135+
refreshTimeout time.Duration
136+
userAgents []string
137+
useIAMAuthN bool
138+
logger debug.ContextLogger
139+
lazyRefresh bool
140+
adminAPIEndpoint string
141+
142+
tokenSource oauth2.TokenSource
143+
credentialsFile string
144+
credentialsJSON []byte
145+
httpClient *http.Client
57146

58147
// disableMetadataExchange is a temporary addition and will be removed in
59148
// future versions.
@@ -62,8 +151,6 @@ type dialerConfig struct {
62151
disableBuiltInTelemetry bool
63152

64153
staticConnInfo io.Reader
65-
// err tracks any dialer options that may have failed.
66-
err error
67154
}
68155

69156
// WithOptions turns a list of Option's into a single Option.
@@ -80,28 +167,15 @@ func WithOptions(opts ...Option) Option {
80167
// authentication.
81168
func WithCredentialsFile(filename string) Option {
82169
return func(d *dialerConfig) {
83-
b, err := os.ReadFile(filename)
84-
if err != nil {
85-
d.err = errtype.NewConfigError(err.Error(), "n/a")
86-
return
87-
}
88-
opt := WithCredentialsJSON(b)
89-
opt(d)
170+
d.credentialsFile = filename
90171
}
91172
}
92173

93174
// WithCredentialsJSON returns an Option that specifies a service account
94175
// or refresh token JSON credentials to be used as the basis for authentication.
95176
func WithCredentialsJSON(b []byte) Option {
96177
return func(d *dialerConfig) {
97-
// TODO: Use AlloyDB-specfic scope
98-
c, err := google.CredentialsFromJSON(context.Background(), b, CloudPlatformScope)
99-
if err != nil {
100-
d.err = errtype.NewConfigError(err.Error(), "n/a")
101-
return
102-
}
103-
d.tokenSource = c.TokenSource
104-
d.clientOpts = append(d.clientOpts, apiopt.WithCredentials(c))
178+
d.credentialsJSON = b
105179
}
106180
}
107181

@@ -125,7 +199,6 @@ func WithDefaultDialOptions(opts ...DialOption) Option {
125199
func WithTokenSource(s oauth2.TokenSource) Option {
126200
return func(d *dialerConfig) {
127201
d.tokenSource = s
128-
d.clientOpts = append(d.clientOpts, apiopt.WithTokenSource(s))
129202
}
130203
}
131204

@@ -150,15 +223,15 @@ func WithRefreshTimeout(t time.Duration) Option {
150223
// advanced use-cases.
151224
func WithHTTPClient(client *http.Client) Option {
152225
return func(d *dialerConfig) {
153-
d.clientOpts = append(d.clientOpts, apiopt.WithHTTPClient(client))
226+
d.httpClient = client
154227
}
155228
}
156229

157230
// WithAdminAPIEndpoint configures the underlying AlloyDB Admin API client to
158231
// use the provided URL.
159232
func WithAdminAPIEndpoint(url string) Option {
160233
return func(d *dialerConfig) {
161-
d.alloydbClientOpts = append(d.alloydbClientOpts, apiopt.WithEndpoint(url))
234+
d.adminAPIEndpoint = url
162235
}
163236
}
164237

0 commit comments

Comments
 (0)